├── .dockerignore ├── .github └── workflows │ ├── ci.yml │ └── deploy.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── Secrets.dev.toml.template ├── assets ├── OpenSans-LICENSE.txt ├── OpenSans.ttf ├── conrad.png └── server-icons │ ├── bases │ ├── server_icon_base.png │ └── server_icon_circle_base.png │ ├── disabled │ └── server_icon_grayscale.png │ ├── server_icon_apple.png │ ├── server_icon_b.png │ ├── server_icon_badly_drawn.png │ ├── server_icon_banne_emoji.png │ ├── server_icon_benin.png │ ├── server_icon_bulb.png │ ├── server_icon_crayon.png │ ├── server_icon_demi_pan.png │ ├── server_icon_fancy.png │ ├── server_icon_ferris.png │ ├── server_icon_ferris_book.png │ ├── server_icon_ferris_wheel.png │ ├── server_icon_ferrisbut.png │ ├── server_icon_fireworks.png │ ├── server_icon_food.png │ ├── server_icon_gay_trans.gif │ ├── server_icon_gimp.png │ ├── server_icon_glasses.png │ ├── server_icon_gopher.png │ ├── server_icon_gopher_eyes.gif │ ├── server_icon_gopher_eyes.png │ ├── server_icon_hamburger.png │ ├── server_icon_handbag.png │ ├── server_icon_ice_cream.png │ ├── server_icon_ireland.png │ ├── server_icon_lemon.png │ ├── server_icon_lgbt.gif │ ├── server_icon_lgbt.png │ ├── server_icon_lgbt_2.png │ ├── server_icon_lgbt_ace.gif │ ├── server_icon_lgbt_demi.gif │ ├── server_icon_lgbt_pan.gif │ ├── server_icon_lgbt_poc.gif │ ├── server_icon_lgbt_polyam.gif │ ├── server_icon_melo_old.png │ ├── server_icon_melon.png │ ├── server_icon_microphone.png │ ├── server_icon_owo.png │ ├── server_icon_palestine_peace.png │ ├── server_icon_pie.png │ ├── server_icon_pineapple.png │ ├── server_icon_pineapple_pizza.png │ ├── server_icon_sandwich.png │ ├── server_icon_shower.png │ ├── server_icon_star.png │ ├── server_icon_sun.png │ ├── server_icon_sunglasses.png │ ├── server_icon_tada.png │ ├── server_icon_thermometer.png │ ├── server_icon_turbofish.png │ ├── server_icon_turtle.png │ ├── server_icon_turtle_christmas.png │ ├── server_icon_unsafe.png │ └── server_icon_up_and_down.gif ├── build.rs ├── migrations └── 0001_init.sql ├── rustfmt.toml └── src ├── checks.rs ├── commands.rs ├── commands ├── crates.rs ├── crates │ └── tests.rs ├── godbolt.rs ├── godbolt │ └── targets.rs ├── man.rs ├── modmail.rs ├── playground.rs ├── playground │ ├── api.rs │ ├── microbench.rs │ ├── misc_commands.rs │ ├── play_eval.rs │ ├── procmacro.rs │ └── util.rs ├── thread_pin.rs └── utilities.rs ├── helpers.rs ├── main.rs └── types.rs /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | ** 3 | !Cargo.toml 4 | !Cargo.lock 5 | !/src 6 | !/migrations 7 | !sqlx-data.json 8 | !/assets 9 | !shuttle_prebuild.sh 10 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [ push, pull_request ] 2 | 3 | name: CI 4 | 5 | jobs: 6 | test: 7 | name: Check Suite 8 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | name: 13 | - stable 14 | - beta 15 | - macOS 16 | - Windows 17 | - no features 18 | 19 | include: 20 | - name: beta 21 | toolchain: beta 22 | - name: macOS 23 | os: macOS-latest 24 | - name: Windows 25 | os: windows-latest 26 | - name: no features 27 | feature_flags: --no-default-features 28 | 29 | steps: 30 | - uses: actions/checkout@v4 31 | - uses: actions-rs/toolchain@v1 32 | with: 33 | profile: minimal 34 | toolchain: ${{ matrix.toolchain || 'stable' }} 35 | override: true 36 | - run: cargo check ${{ matrix.feature_flags || '--all-features' }} 37 | 38 | fmt: 39 | name: Rustfmt 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - uses: actions-rs/toolchain@v1 44 | with: 45 | profile: minimal 46 | toolchain: stable 47 | override: true 48 | - run: rustup component add rustfmt 49 | - run: cargo fmt --all -- --check 50 | 51 | clippy: 52 | name: Clippy 53 | runs-on: ubuntu-latest 54 | steps: 55 | - uses: actions/checkout@v4 56 | - uses: actions-rs/toolchain@v1 57 | with: 58 | profile: minimal 59 | toolchain: stable 60 | override: true 61 | - run: rustup component add clippy 62 | - run: cargo clippy --all-features -- -D warnings 63 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: "shuttle.rs deploy prod" 2 | 3 | on: 4 | push: 5 | branches: 6 | - "main" 7 | 8 | jobs: 9 | deploy: 10 | runs-on: "ubuntu-latest" 11 | environment: "production" 12 | steps: 13 | - uses: shuttle-hq/deploy-action@v2 14 | with: 15 | shuttle-api-key: ${{ secrets.SHUTTLE_DEPLOY_KEY }} 16 | project-id: 'proj_01JK1SA5EGYWSNFR7WC9AV5NCJ' 17 | extra-args: --allow-dirty --debug 18 | secrets: | 19 | DISCORD_TOKEN = '${{ secrets.DISCORD_TOKEN }}' 20 | DISCORD_GUILD = '${{ vars.DISCORD_GUILD }}' 21 | APPLICATION_ID = '${{ vars.APPLICATION_ID }}' 22 | MOD_ROLE_ID = '${{ vars.MOD_ROLE_ID }}' 23 | RUSTACEAN_ROLE_ID = '${{ vars.RUSTACEAN_ROLE_ID }}' 24 | MODMAIL_CHANNEL_ID = '${{ vars.MODMAIL_CHANNEL_ID }}' 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | debug/ 4 | target/ 5 | 6 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 7 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 8 | Cargo.lock 9 | 10 | # These are backup files generated by rustfmt 11 | **/*.rs.bk 12 | 13 | # MSVC Windows builds of rustc generate these, which store debugging information 14 | *.pdb 15 | 16 | # Misc secret files 17 | .env 18 | .*/ 19 | Secrets.toml 20 | Secrets.dev.toml 21 | 22 | # Allow the .github folder 23 | !.github/ 24 | /target 25 | .shuttle* 26 | Secrets*.toml 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via a GitHub 4 | issue before making a change. 5 | 6 | Please note we follow the [Rust Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct). 7 | Please follow it in all your interactions with the project. 8 | 9 | ## Pull Request Process 10 | 11 | 1. Ensure that you've provided the reasoning behind the change in the description of your PR. 12 | 2. Await code review and approval by the repository reviewers. 13 | 3. If there are things that need to be changed, you'll be informed of them. 14 | 4. Once everything looks good, it'll be merged. 15 | 5. If the change is deemed undesirable, the PR will be closed without merging. 16 | 17 | ## Server Icon Submissions 18 | 19 | Server icons can be as creative as you want! However, there are some baselines that should be followed: 20 | 21 | 1. The server icon should contain the Ferris from the cover of the Rust book. The bases are located 22 | in the `assets/server-icons/bases/` folder. 23 | 2. The file name should follow the pattern `server_icon_.`. 24 | 25 | That way people can still recognize the server icon at a glance, which is important for a 26 | lot of people. 27 | 28 | Additionally, the server icon is supposed to represent either silly ideas or support for a cause. Here are a few non-exhaustive examples of things that are allowed and disallowed: 29 | 30 | Allowed: 31 | 32 | 1. References to LGBTQ+ causes 33 | 2. References to BLM causes 34 | 3. Inside jokes of the Discord server 35 | 4. Ferris holding the logo of other programming languages 36 | 37 | Disallowed: 38 | 39 | 1. References to Nazism/pedophilia/zoophilia (or any cause that is generally not socially accepted) 40 | 2. Ferris holding anything that implies a programming language is bad 41 | (for example, Ferris holding a flag that says `Rust > C++`, or Ferris holding the PHP logo with a 🚫 on top of it) 42 | 3. References to NSFW topics 43 | 44 | ## Code of Conduct 45 | 46 | ### Our Pledge 47 | 48 | In the interest of fostering an open and welcoming environment, we as 49 | contributors and maintainers pledge to making participation in our project and 50 | our community a harassment-free experience for everyone, regardless of age, body 51 | size, disability, ethnicity, gender identity and expression, level of experience, 52 | nationality, personal appearance, race, religion, or sexual identity and 53 | orientation. 54 | 55 | ### Our Standards 56 | 57 | Examples of behavior that contributes to creating a positive environment 58 | include: 59 | 60 | * Using welcoming and inclusive language 61 | * Being respectful of differing viewpoints and experiences 62 | * Gracefully accepting constructive criticism 63 | * Focusing on what is best for the community 64 | * Showing empathy towards other community members 65 | 66 | Examples of unacceptable behavior by participants include: 67 | 68 | * The use of sexualized language or imagery and unwelcome sexual attention or 69 | advances 70 | * Trolling, insulting/derogatory comments, and personal or political attacks 71 | * Public or private harassment 72 | * Publishing others' private information, such as a physical or electronic 73 | address, without explicit permission 74 | * Other conduct which could reasonably be considered inappropriate in a 75 | professional setting 76 | 77 | ### Our Responsibilities 78 | 79 | Project maintainers are responsible for clarifying the standards of acceptable 80 | behavior and are expected to take appropriate and fair corrective action in 81 | response to any instances of unacceptable behavior. 82 | 83 | Project maintainers have the right and responsibility to remove, edit, or 84 | reject comments, commits, code, wiki edits, issues, and other contributions 85 | that are not aligned to this Code of Conduct, or to ban temporarily or 86 | permanently any contributor for other behaviors that they deem inappropriate, 87 | threatening, offensive, or harmful. 88 | 89 | ### Scope 90 | 91 | This Code of Conduct applies both within project spaces and in public spaces 92 | when an individual is representing the project or its community. Examples of 93 | representing a project or community include using an official project e-mail 94 | address, posting via an official social media account, or acting as an appointed 95 | representative at an online or offline event. Representation of a project may be 96 | further defined and clarified by project maintainers. 97 | 98 | ### Enforcement 99 | 100 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 101 | reported by opening a modmail in the [repository's associated Discord](https://discord.gg/rust-lang-community). 102 | All complaints will be reviewed and investigated and will result in a response that 103 | is deemed necessary and appropriate to the circumstances. The project team is 104 | obligated to maintain confidentiality with regard to the reporter of an incident. 105 | Further details of specific enforcement policies may be posted separately. 106 | 107 | Project maintainers who do not follow or enforce the Code of Conduct in good 108 | faith may face temporary or permanent repercussions as determined by other 109 | members of the project's leadership. 110 | 111 | ### Attribution 112 | 113 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 114 | available at [http://contributor-covenant.org/version/1/4][version] 115 | 116 | [homepage]: http://contributor-covenant.org 117 | [version]: http://contributor-covenant.org/version/1/4/ 118 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ferrisbot-for-discord" 3 | version = "0.1.0" 4 | authors = [ 5 | "kangalioo", 6 | "technetos ", 7 | "Ivan Dardi ", 8 | ] 9 | edition = "2021" 10 | license = "MIT" 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [dependencies] 15 | shuttle-runtime = "0.53.0" 16 | shuttle-serenity = "0.53.0" 17 | shuttle-shared-db = { version = "0.53.0", features = ["postgres", "sqlx"] } 18 | poise = "0.6" 19 | anyhow = "1.0" 20 | tokio = "1.44" 21 | tracing = "0.1.41" 22 | serde = { version = "1.0", features = ["derive"] } 23 | serde_json = "1.0" 24 | sqlx = { version = "0.8.3", features = [ 25 | "runtime-tokio-native-tls", 26 | "postgres", 27 | "macros", 28 | ] } 29 | reqwest = { version = "0.12", default-features = false, features = [ 30 | "json", 31 | "rustls-tls", 32 | ] } 33 | image = { version = "0.25", default-features = false, features = [ 34 | "png", 35 | ] } # get a better computer meme rendering 36 | imageproc = { version = "0.25", default-features = false } # get a better computer meme rendering 37 | ab_glyph = { version = "0.2", default-features = false } # interact with imageproc 38 | rand = "0.9" 39 | syn = { version = "2.0.100", features = ["full"] } 40 | itertools = "0.14" 41 | futures = "0.3.31" 42 | proc-macro2 = { version = "1.0.94", features = ["span-locations"] } 43 | implicit-fn = "0.1.0" 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ivan Dardi, kangalioo, technetos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Rustbot 2 | 3 | ## Inviting the bot 4 | 5 | Some permissions are required: 6 | - Send Messages: base command functionality 7 | - Manage Roles: for `?rustify` command 8 | - Manage Messages: for `?cleanup` command 9 | - Add Reactions: for `?rustify` command feedback 10 | Furthermore, the `applications.commands` OAuth2 scope is required for slash commands. 11 | 12 | Here's an invite link to an instance hosted by @kangalioo on my Raspberry Pi, with the permissions and scopes incorporated: 13 | https://discord.com/oauth2/authorize?client_id=804340127433752646&permissions=268445760&scope=bot%20applications.commands 14 | 15 | Adjust the `client_id` in the URL for your own hosted instances of the bot. 16 | 17 | ## Hosting the bot 18 | 19 | The bot requires `Server Members Intent` enabled in the `Applications > $YOUR_BOTS_NAME > Bot` 20 | settings of Discord's [developer portal](https://discord.com/developers/applications). 21 | 22 | The bot uses shuttle.rs to run, so you'll have to run the bot using `cargo shuttle run --release`. 23 | 24 | The `Secrets.dev.toml.template` contains an example of the necessary `Secrets.dev.toml` file for local development. 25 | 26 | ## Credits 27 | 28 | This codebase has its roots in [rust-lang/discord-mods-bot](https://github.com/rust-lang/discord-mods-bot/), the Discord bot running on the official Rust server. 29 | -------------------------------------------------------------------------------- /Secrets.dev.toml.template: -------------------------------------------------------------------------------- 1 | # The Discord bot token acquired via the Discord Developer Portal 2 | DISCORD_TOKEN="" 3 | 4 | # ID of the Discord guild that the application will run on 5 | DISCORD_GUILD="" 6 | 7 | # ID of your Discord bot application 8 | APPLICATION_ID="" 9 | 10 | # ID of the Moderator role. Potentially used someday for `?cleanup` command 11 | MOD_ROLE_ID="" 12 | 13 | # ID of the Rustacean role. Used for `?rustify` command 14 | RUSTACEAN_ROLE_ID="" 15 | 16 | # ID of the channel to send modmail to 17 | MODMAIL_CHANNEL_ID="" 18 | 19 | # The duration to wait before refreshing the godbolt targets list 20 | GODBOLT_UPDATE_DURATION="1" 21 | -------------------------------------------------------------------------------- /assets/OpenSans-LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /assets/OpenSans.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/OpenSans.ttf -------------------------------------------------------------------------------- /assets/conrad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/conrad.png -------------------------------------------------------------------------------- /assets/server-icons/bases/server_icon_base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/bases/server_icon_base.png -------------------------------------------------------------------------------- /assets/server-icons/bases/server_icon_circle_base.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/bases/server_icon_circle_base.png -------------------------------------------------------------------------------- /assets/server-icons/disabled/server_icon_grayscale.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/disabled/server_icon_grayscale.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_apple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_apple.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_b.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_b.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_badly_drawn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_badly_drawn.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_banne_emoji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_banne_emoji.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_benin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_benin.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_bulb.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_bulb.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_crayon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_crayon.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_demi_pan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_demi_pan.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_fancy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_fancy.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_ferris.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_ferris.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_ferris_book.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_ferris_book.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_ferris_wheel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_ferris_wheel.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_ferrisbut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_ferrisbut.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_fireworks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_fireworks.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_food.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_food.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_gay_trans.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_gay_trans.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_gimp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_gimp.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_glasses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_glasses.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_gopher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_gopher.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_gopher_eyes.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_gopher_eyes.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_gopher_eyes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_gopher_eyes.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_hamburger.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_hamburger.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_handbag.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_handbag.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_ice_cream.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_ice_cream.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_ireland.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_ireland.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lemon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lemon.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt_2.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt_ace.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt_ace.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt_demi.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt_demi.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt_pan.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt_pan.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt_poc.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt_poc.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_lgbt_polyam.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_lgbt_polyam.gif -------------------------------------------------------------------------------- /assets/server-icons/server_icon_melo_old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_melo_old.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_melon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_melon.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_microphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_microphone.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_owo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_owo.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_palestine_peace.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_palestine_peace.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_pie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_pie.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_pineapple.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_pineapple.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_pineapple_pizza.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_pineapple_pizza.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_sandwich.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_sandwich.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_shower.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_shower.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_star.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_sun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_sun.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_sunglasses.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_sunglasses.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_tada.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_tada.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_thermometer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_thermometer.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_turbofish.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_turbofish.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_turtle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_turtle.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_turtle_christmas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_turtle_christmas.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_unsafe.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_unsafe.png -------------------------------------------------------------------------------- /assets/server-icons/server_icon_up_and_down.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/assets/server-icons/server_icon_up_and_down.gif -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | println!("cargo:rerun-if-changed=.git/HEAD"); 3 | if let Some(rev) = rev_parse() { 4 | println!("cargo:rustc-env=RUSTBOT_REV={}", rev); 5 | } 6 | } 7 | 8 | /// Retrieves SHA-1 git revision. Returns `None` if any step of the way fails, 9 | /// since this is only nice to have for the ?revision command and shouldn't fail builds. 10 | fn rev_parse() -> Option { 11 | let output = std::process::Command::new("git") 12 | .args(["rev-parse", "--short=9", "HEAD"]) 13 | .output() 14 | .ok()?; 15 | if !output.status.success() { 16 | return None; 17 | } 18 | String::from_utf8(output.stdout).ok() 19 | } 20 | -------------------------------------------------------------------------------- /migrations/0001_init.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rust-community-discord/ferrisbot-for-discord/b60b725caf2d6f83f0f98edc24dd4161ea532afc/migrations/0001_init.sql -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | use_field_init_shorthand = true 3 | use_try_shorthand = true 4 | -------------------------------------------------------------------------------- /src/checks.rs: -------------------------------------------------------------------------------- 1 | use crate::types::Context; 2 | 3 | #[must_use] 4 | pub fn is_moderator(ctx: Context<'_>) -> bool { 5 | let mod_role_id = ctx.data().mod_role_id; 6 | match ctx { 7 | Context::Application(app_context) => { 8 | let Some(member) = &app_context.interaction.member else { 9 | // Invoked outside guild 10 | return false; 11 | }; 12 | 13 | member.roles.contains(&mod_role_id) 14 | } 15 | Context::Prefix(msg_context) => { 16 | let Some(member) = &msg_context.msg.member else { 17 | // Command triggered outside MessageCreateEvent? 18 | return false; 19 | }; 20 | 21 | member.roles.contains(&mod_role_id) 22 | } 23 | } 24 | } 25 | 26 | pub async fn check_is_moderator(ctx: Context<'_>) -> anyhow::Result { 27 | let user_has_moderator_role = is_moderator(ctx); 28 | if !user_has_moderator_role { 29 | ctx.send( 30 | poise::CreateReply::default() 31 | .content("This command is only available to moderators.") 32 | .ephemeral(true), 33 | ) 34 | .await?; 35 | } 36 | 37 | Ok(user_has_moderator_role) 38 | } 39 | -------------------------------------------------------------------------------- /src/commands.rs: -------------------------------------------------------------------------------- 1 | pub use godbolt::*; 2 | pub use playground::*; 3 | 4 | pub mod crates; 5 | pub mod godbolt; 6 | pub mod man; 7 | pub mod modmail; 8 | pub mod playground; 9 | pub mod thread_pin; 10 | pub mod utilities; 11 | -------------------------------------------------------------------------------- /src/commands/crates.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use anyhow::{anyhow, bail}; 3 | use futures::stream::FuturesUnordered; 4 | use futures::StreamExt; 5 | use reqwest::header; 6 | use serde::Deserialize; 7 | use tracing::info; 8 | 9 | use crate::serenity; 10 | use crate::types::Context; 11 | 12 | #[cfg(test)] 13 | mod tests; 14 | 15 | const USER_AGENT: &str = "kangalioo/rustbot"; 16 | 17 | #[derive(Debug, Deserialize)] 18 | struct Crates { 19 | crates: Vec, 20 | } 21 | 22 | #[derive(Debug, Deserialize)] 23 | struct Crate { 24 | name: String, 25 | // newest_version: String, // https://github.com/kangalioo/rustbot/issues/23 26 | max_version: Option, 27 | max_stable_version: Option, 28 | // sometimes null empirically 29 | updated_at: String, 30 | downloads: u64, 31 | description: Option, 32 | documentation: Option, 33 | exact_match: bool, 34 | } 35 | 36 | /// Queries the crates.io crates list for a specific crate 37 | async fn get_crate(http: &reqwest::Client, query: &str) -> Result { 38 | info!("searching for crate `{}`", query); 39 | 40 | let crate_list = http 41 | .get("https://crates.io/api/v1/crates") 42 | .header(header::USER_AGENT, USER_AGENT) 43 | .query(&[("q", query)]) 44 | .send() 45 | .await? 46 | .json::() 47 | .await 48 | .map_err(|e| anyhow!("Cannot parse crates.io JSON response (`{}`)", e))?; 49 | 50 | let crate_ = crate_list 51 | .crates 52 | .into_iter() 53 | .next() 54 | .ok_or_else(|| anyhow!("Crate `{}` not found", query))?; 55 | 56 | if crate_.exact_match { 57 | Ok(crate_) 58 | } else { 59 | bail!( 60 | "Crate `{}` not found. Did you mean `{}`?", 61 | query, 62 | crate_.name 63 | ) 64 | } 65 | } 66 | 67 | fn get_documentation(crate_: &Crate) -> String { 68 | match &crate_.documentation { 69 | Some(doc) => doc.to_owned(), 70 | None => format!("https://docs.rs/{}", crate_.name), 71 | } 72 | } 73 | 74 | /// 6051423 -> "6 051 423" 75 | fn format_number(mut n: u64) -> String { 76 | let mut output = String::new(); 77 | while n >= 1000 { 78 | output.insert_str(0, &format!(" {:03}", n % 1000)); 79 | n /= 1000; 80 | } 81 | output.insert_str(0, &format!("{n}")); 82 | output 83 | } 84 | 85 | async fn autocomplete_crate(ctx: Context<'_>, partial: &str) -> impl Iterator { 86 | let http = &ctx.data().http; 87 | 88 | let response = http 89 | .get("https://crates.io/api/v1/crates") 90 | .header(header::USER_AGENT, USER_AGENT) 91 | .query(&[("q", partial), ("per_page", "25"), ("sort", "downloads")]) 92 | .send() 93 | .await; 94 | 95 | let crate_list = match response { 96 | Ok(response) => response.json::().await.ok(), 97 | Err(_) => None, 98 | }; 99 | 100 | crate_list 101 | .map_or(Vec::new(), |list| list.crates) 102 | .into_iter() 103 | .map(|crate_| crate_.name) 104 | } 105 | 106 | /// Lookup crates on crates.io 107 | /// 108 | /// Search for a crate on crates.io 109 | /// ``` 110 | /// ?crate crate_name 111 | /// ``` 112 | #[poise::command( 113 | prefix_command, 114 | slash_command, 115 | rename = "crate", 116 | broadcast_typing, 117 | category = "Crates" 118 | )] 119 | pub async fn crate_( 120 | ctx: Context<'_>, 121 | #[description = "Name of the searched crate"] 122 | #[autocomplete = "autocomplete_crate"] 123 | crate_name: String, 124 | ) -> Result<()> { 125 | if let Some(url) = rustc_crate_link(&crate_name) { 126 | ctx.say(url).await?; 127 | return Ok(()); 128 | } 129 | 130 | let crate_ = get_crate(&ctx.data().http, &crate_name).await?; 131 | 132 | ctx.send( 133 | poise::CreateReply::default().embed( 134 | serenity::CreateEmbed::new() 135 | .title(&crate_.name) 136 | .url(get_documentation(&crate_)) 137 | .description( 138 | crate_ 139 | .description 140 | .as_deref() 141 | .unwrap_or("__"), 142 | ) 143 | .field( 144 | "Version", 145 | crate_ 146 | .max_stable_version 147 | .or(crate_.max_version) 148 | .unwrap_or_else(|| "".into()), 149 | true, 150 | ) 151 | .field("Downloads", format_number(crate_.downloads), true) 152 | .timestamp( 153 | crate_ 154 | .updated_at 155 | .parse::() 156 | .unwrap_or(serenity::Timestamp::now()), 157 | ) 158 | .color(crate::types::EMBED_COLOR), 159 | ), 160 | ) 161 | .await?; 162 | 163 | Ok(()) 164 | } 165 | 166 | /// Returns whether the given type name is the one of a primitive. 167 | #[rustfmt::skip] 168 | fn is_in_std(name: &str) -> IsInStd<'_> { 169 | match name { 170 | "f32" | "f64" 171 | | "i8" | "i16" | "i32" | "i64" | "i128" | "isize" 172 | | "u8" | "u16" | "u32" | "u64" | "u128" | "usize" 173 | | "char" | "str" 174 | | "pointer" | "reference" | "fn" 175 | | "bool" | "slice" | "tuple" | "unit" | "array" 176 | => IsInStd::Primitive, 177 | "f16" | "f128" | "never" => IsInStd::PrimitiveNightly, 178 | "Self" => IsInStd::Keyword("SelfTy"), // special case: SelfTy is not a real keyword 179 | "SelfTy" 180 | | "as" | "async" | "await" | "break" | "const" | "continue" | "crate" | "dyn" | "else" | "enum" | "extern" | "false" 181 | | "for" | "if" | "impl" | "in" | "let" | "loop" | "match" | "mod" | "move" | "mut" | "pub" | "ref" | "return" 182 | | "self" | "static" | "struct" | "super" | "trait" | "true" | "type" | "union" | "unsafe" | "use" | "where" | "while" 183 | // omitted "fn" due to duplicate 184 | => IsInStd::Keyword(name), 185 | name if name.chars().next().is_some_and(char::is_uppercase) => IsInStd::PossibleType, 186 | _ => IsInStd::False 187 | } 188 | } 189 | 190 | #[derive(Debug)] 191 | enum IsInStd<'a> { 192 | PossibleType, 193 | Primitive, 194 | PrimitiveNightly, 195 | Keyword(&'a str), 196 | False, 197 | } 198 | 199 | /// Provide the documentation link to an official Rust crate (e.g. std, alloc, nightly) 200 | fn rustc_crate_link(crate_name: &str) -> Option<&'static str> { 201 | match crate_name.to_ascii_lowercase().as_str() { 202 | "std" => Some("https://doc.rust-lang.org/stable/std/"), 203 | "core" => Some("https://doc.rust-lang.org/stable/core/"), 204 | "alloc" => Some("https://doc.rust-lang.org/stable/alloc/"), 205 | "proc_macro" => Some("https://doc.rust-lang.org/stable/proc_macro/"), 206 | "beta" => Some("https://doc.rust-lang.org/beta/std/"), 207 | "nightly" => Some("https://doc.rust-lang.org/nightly/std/"), 208 | "rustc" => Some("https://doc.rust-lang.org/nightly/nightly-rustc/"), 209 | "test" => Some("https://doc.rust-lang.org/stable/test"), 210 | _ => None, 211 | } 212 | } 213 | 214 | /// Lookup documentation 215 | /// 216 | /// Retrieve documentation for a given crate 217 | /// ``` 218 | /// ?docs crate_name::module::item 219 | /// ``` 220 | #[poise::command( 221 | prefix_command, 222 | aliases("docs"), 223 | broadcast_typing, 224 | track_edits, 225 | slash_command, 226 | category = "Crates" 227 | )] 228 | pub async fn doc( 229 | ctx: Context<'_>, 230 | #[description = "Path of the crate and item to lookup"] query: String, 231 | ) -> Result<()> { 232 | ctx.say(path_to_doc_url(&query, &ctx.data().http).await?) 233 | .await?; 234 | 235 | Ok(()) 236 | } 237 | 238 | async fn path_to_doc_url(query: &str, client: &impl DocsClient) -> Result { 239 | use std::fmt::Write; 240 | 241 | let mut path = split_qualified_path(query); 242 | 243 | if path.ident.is_none() { 244 | // no `::`, possible ident from std 245 | match is_in_std(path.crate_) { 246 | IsInStd::Primitive => { 247 | path = QualifiedPath { 248 | kind: Some("primitive"), 249 | crate_: "std", 250 | ident: Some(path.crate_), 251 | mods: "", 252 | }; 253 | } 254 | IsInStd::PrimitiveNightly => { 255 | path = QualifiedPath { 256 | kind: Some("primitive"), 257 | crate_: "nightly", 258 | ident: Some(path.crate_), 259 | mods: "", 260 | }; 261 | } 262 | IsInStd::Keyword(ident) => { 263 | path = QualifiedPath { 264 | kind: Some("keyword"), 265 | crate_: "std", 266 | ident: Some(ident), 267 | mods: "", 268 | }; 269 | } 270 | IsInStd::PossibleType => { 271 | path = QualifiedPath { 272 | kind: path.kind, 273 | crate_: "std", 274 | ident: Some(path.crate_), 275 | mods: "", 276 | }; 277 | } 278 | IsInStd::False => {} 279 | } 280 | } 281 | 282 | let (is_rustc_crate, mut doc_url, root_len) = 283 | if let Some(prefix) = rustc_crate_link(path.crate_) { 284 | (true, prefix.to_owned(), prefix.len()) 285 | } else { 286 | let mut prefix = client.get_crate_docs(path.crate_).await?; 287 | let root_len = prefix.len(); 288 | 289 | if !prefix.ends_with('/') { 290 | prefix += "/"; 291 | } 292 | write!(prefix, "latest/{}/", path.crate_.replace('-', "_")).unwrap(); 293 | (false, prefix, root_len) 294 | }; 295 | 296 | if let Some(ident) = path.ident { 297 | for segment in path.mods.split("::") { 298 | if !segment.is_empty() { 299 | doc_url += segment; 300 | doc_url += "/"; 301 | } 302 | } 303 | 304 | let kind = if let Some(kind) = path.kind { 305 | Some(kind) 306 | } else { 307 | guess_kind(client, &doc_url, is_rustc_crate, ident).await 308 | }; 309 | 310 | match kind { 311 | Some("" | "mod") => write!(doc_url, "{ident}/index.html").unwrap(), 312 | Some(kind) => write!(doc_url, "{kind}.{ident}.html").unwrap(), 313 | None => { 314 | doc_url.truncate(root_len); 315 | doc_url += "?search="; 316 | if !path.mods.is_empty() { 317 | doc_url += path.mods; 318 | doc_url += "::"; 319 | } 320 | doc_url += ident; 321 | } 322 | } 323 | } else { 324 | doc_url.truncate(root_len); 325 | } 326 | 327 | Ok(doc_url) 328 | } 329 | 330 | fn split_qualified_path(input: &str) -> QualifiedPath<'_> { 331 | let (kind, path) = match input.split_once('@') { 332 | Some((kind, rest)) => (Some(kind), rest), 333 | None => (None, input), 334 | }; 335 | 336 | let Some((crate_, rest)) = path.split_once("::") else { 337 | return QualifiedPath { 338 | kind, 339 | crate_: path, 340 | ident: None, 341 | mods: "", 342 | }; 343 | }; 344 | match rest.rsplit_once("::") { 345 | Some((mods, ident)) => QualifiedPath { 346 | kind, 347 | crate_, 348 | ident: Some(ident), 349 | mods, 350 | }, 351 | None => QualifiedPath { 352 | kind, 353 | crate_, 354 | ident: Some(rest), 355 | mods: "", 356 | }, 357 | } 358 | } 359 | 360 | #[derive(Debug)] 361 | struct QualifiedPath<'a> { 362 | kind: Option<&'a str>, 363 | crate_: &'a str, 364 | ident: Option<&'a str>, 365 | mods: &'a str, 366 | } 367 | 368 | // Reference rust/src/tools/rust-analyzer/crates/ide/src/doc_links.rs for an exhaustive list 369 | const SNAKE_CASE_KINDS: &[&str] = &["fn", "macro", "mod"]; 370 | const UPPER_CAMEL_CASE_KINDS: &[&str] = &[ 371 | "struct", 372 | "enum", 373 | "union", 374 | "trait", 375 | "traitalias", 376 | "type", 377 | "derive", 378 | ]; 379 | const SCREAMING_SNAKE_CASE_KINDS: &[&str] = &["constant", "static"]; 380 | const RUSTC_CRATE_ONLY_KINDS: &[&str] = &["keyword", "primitive"]; 381 | 382 | async fn guess_kind( 383 | client: &impl DocsClient, 384 | prefix: &str, 385 | is_rustc_crate: bool, 386 | ident: &str, 387 | ) -> Option<&'static str> { 388 | let mut attempt_order: Vec<&[&'static str]> = 389 | if ident.chars().next().is_some_and(char::is_lowercase) { 390 | vec![ 391 | SNAKE_CASE_KINDS, 392 | UPPER_CAMEL_CASE_KINDS, 393 | SCREAMING_SNAKE_CASE_KINDS, 394 | ] 395 | } else if ident.chars().all(char::is_uppercase) { 396 | vec![ 397 | SCREAMING_SNAKE_CASE_KINDS, 398 | UPPER_CAMEL_CASE_KINDS, 399 | SNAKE_CASE_KINDS, 400 | ] 401 | } else { 402 | vec![ 403 | UPPER_CAMEL_CASE_KINDS, 404 | SCREAMING_SNAKE_CASE_KINDS, 405 | SNAKE_CASE_KINDS, 406 | ] 407 | }; 408 | if is_rustc_crate { 409 | attempt_order.insert(1, RUSTC_CRATE_ONLY_KINDS); 410 | } 411 | 412 | for class in attempt_order { 413 | let results = class 414 | .iter() 415 | .map(|&kind| async move { 416 | let url = if kind == "mod" { 417 | format!("{prefix}{ident}/index.html") 418 | } else { 419 | format!("{prefix}{kind}.{ident}.html") 420 | }; 421 | client.page_exists(&url).await.then_some(kind) 422 | }) 423 | .collect::>() 424 | .filter_map(|result| async move { result }); 425 | futures::pin_mut!(results); 426 | if let Some(kind) = results.next().await { 427 | return Some(kind); 428 | } 429 | } 430 | 431 | None 432 | } 433 | 434 | trait DocsClient { 435 | async fn get_crate_docs(&self, crate_name: &str) -> Result; 436 | async fn page_exists(&self, url: &str) -> bool; 437 | } 438 | 439 | impl DocsClient for reqwest::Client { 440 | async fn get_crate_docs(&self, crate_name: &str) -> Result { 441 | get_crate(self, crate_name) 442 | .await 443 | .map(|crate_| get_documentation(&crate_)) 444 | } 445 | 446 | async fn page_exists(&self, url: &str) -> bool { 447 | self.head(url) 448 | .header(header::USER_AGENT, USER_AGENT) 449 | .send() 450 | .await 451 | .is_ok_and(|resp| resp.status() == reqwest::StatusCode::OK) 452 | } 453 | } 454 | -------------------------------------------------------------------------------- /src/commands/crates/tests.rs: -------------------------------------------------------------------------------- 1 | struct MockDocsClient; 2 | 3 | impl super::DocsClient for MockDocsClient { 4 | async fn page_exists(&self, url: &str) -> bool { 5 | match url { 6 | "https://doc.rust-lang.org/stable/std/char/constant.MAX.html" 7 | | "https://docs.rs/serde-json/latest/serde_json/fn.to_string.html" 8 | | "https://docs.rs/serde-json/latest/serde_json/value/index.html" 9 | | "https://docs.rs/serde-json/latest/serde_json/value/trait.Index.html" => true, 10 | "https://doc.rust-lang.org/stable/std/char/static.MAX.html" 11 | | "https://docs.rs/serde-json/latest/serde_json/macro.to_string.html" 12 | | "https://docs.rs/serde-json/latest/serde_json/to_string/index.html" 13 | | "https://docs.rs/serde-json/latest/serde_json/macro.value.html" 14 | | "https://docs.rs/serde-json/latest/serde_json/fn.value.html" 15 | | "https://docs.rs/serde-json/latest/serde_json/value/struct.Index.html" 16 | | "https://docs.rs/serde-json/latest/serde_json/value/enum.Index.html" 17 | | "https://docs.rs/serde-json/latest/serde_json/value/union.Index.html" 18 | | "https://docs.rs/serde-json/latest/serde_json/value/traitalias.Index.html" 19 | | "https://docs.rs/serde-json/latest/serde_json/value/type.Index.html" 20 | | "https://docs.rs/serde-json/latest/serde_json/value/derive.Index.html" => false, 21 | _ if url.starts_with("https://docs.rs/serde-json/latest/serde_json/non/existent") => { 22 | false 23 | } 24 | _ => panic!("unexpected query {url:?}"), 25 | } 26 | } 27 | 28 | async fn get_crate_docs(&self, crate_name: &str) -> anyhow::Result { 29 | match crate_name { 30 | "serde-json" => Ok("https://docs.rs/serde-json".to_owned()), 31 | _ => panic!("unexpected query {crate_name:?}"), 32 | } 33 | } 34 | } 35 | 36 | #[tokio::test] 37 | async fn path_to_doc_url_stable_std() { 38 | test_path_to_doc_url("std", "https://doc.rust-lang.org/stable/std/").await; 39 | } 40 | 41 | #[tokio::test] 42 | async fn path_to_doc_url_self_ty() { 43 | test_path_to_doc_url( 44 | "Self", 45 | "https://doc.rust-lang.org/stable/std/keyword.SelfTy.html", 46 | ) 47 | .await; 48 | } 49 | 50 | #[tokio::test] 51 | async fn path_to_doc_url_std_primitive() { 52 | test_path_to_doc_url( 53 | "f128", 54 | "https://doc.rust-lang.org/nightly/std/primitive.f128.html", 55 | ) 56 | .await; 57 | } 58 | 59 | #[tokio::test] 60 | async fn path_to_doc_url_nightly_std() { 61 | test_path_to_doc_url("nightly", "https://doc.rust-lang.org/nightly/std/").await; 62 | } 63 | 64 | #[tokio::test] 65 | async fn path_to_doc_url_stable_guessed_const() { 66 | test_path_to_doc_url( 67 | "std::char::MAX", 68 | "https://doc.rust-lang.org/stable/std/char/constant.MAX.html", 69 | ) 70 | .await; 71 | } 72 | 73 | #[tokio::test] 74 | async fn path_to_doc_url_crate_docs() { 75 | test_path_to_doc_url("serde-json", "https://docs.rs/serde-json").await; 76 | } 77 | 78 | #[tokio::test] 79 | async fn path_to_doc_url_guessed_fn() { 80 | test_path_to_doc_url( 81 | "serde-json::to_string", 82 | "https://docs.rs/serde-json/latest/serde_json/fn.to_string.html", 83 | ) 84 | .await; 85 | } 86 | 87 | #[tokio::test] 88 | async fn path_to_doc_url_guessed_mod() { 89 | test_path_to_doc_url( 90 | "serde-json::value", 91 | "https://docs.rs/serde-json/latest/serde_json/value/index.html", 92 | ) 93 | .await; 94 | } 95 | 96 | #[tokio::test] 97 | async fn path_to_doc_url_guessed_trait() { 98 | test_path_to_doc_url( 99 | "serde-json::value::Index", 100 | "https://docs.rs/serde-json/latest/serde_json/value/trait.Index.html", 101 | ) 102 | .await; 103 | } 104 | 105 | #[tokio::test] 106 | async fn path_to_doc_url_search() { 107 | test_path_to_doc_url( 108 | "serde-json::non::existent::symbol", 109 | "https://docs.rs/serde-json?search=non::existent::symbol", 110 | ) 111 | .await; 112 | } 113 | 114 | async fn test_path_to_doc_url(path: &str, expect: &str) { 115 | assert_eq!( 116 | super::path_to_doc_url(path, &MockDocsClient).await.unwrap(), 117 | expect, 118 | "{path} should resolve to {expect}", 119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /src/commands/godbolt.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, mem::take}; 2 | 3 | use anyhow::{anyhow, Error}; 4 | use poise::{CodeBlockError, KeyValueArgs}; 5 | use syn::spanned::Spanned; 6 | use tracing::warn; 7 | 8 | use crate::types::Context; 9 | 10 | mod targets; 11 | pub use targets::*; 12 | 13 | const LLVM_MCA_TOOL_ID: &str = "llvm-mcatrunk"; 14 | 15 | struct Compilation { 16 | output: String, 17 | stderr: String, 18 | } 19 | 20 | #[derive(Debug, serde::Deserialize)] 21 | struct GodboltOutputSegment { 22 | text: String, 23 | } 24 | 25 | #[derive(Debug, serde::Deserialize)] 26 | struct GodboltOutput(Vec); 27 | 28 | impl GodboltOutput { 29 | pub fn concatenate(&self) -> String { 30 | let mut complete_text = String::new(); 31 | for segment in &self.0 { 32 | complete_text.push_str(&segment.text); 33 | complete_text.push('\n'); 34 | } 35 | complete_text 36 | } 37 | } 38 | 39 | #[derive(Debug, serde::Deserialize)] 40 | struct GodboltResponse { 41 | // stdout: GodboltOutput, 42 | stderr: GodboltOutput, 43 | asm: GodboltOutput, 44 | tools: Vec, 45 | } 46 | 47 | #[derive(Debug, serde::Deserialize)] 48 | struct GodboltTool { 49 | id: String, 50 | // code: u8, 51 | stdout: GodboltOutput, 52 | // stderr: GodboltOutput, 53 | } 54 | 55 | struct GodboltRequest<'a> { 56 | source_code: &'a str, 57 | rustc: &'a str, 58 | flags: &'a str, 59 | run_llvm_mca: bool, 60 | } 61 | 62 | /// Compile a given Rust source code file on Godbolt using the latest nightly compiler with 63 | /// full optimizations (-O3) 64 | /// Returns a multiline string with the pretty printed assembly 65 | async fn compile_rust_source( 66 | http: &reqwest::Client, 67 | request: &GodboltRequest<'_>, 68 | ) -> Result { 69 | let tools = if request.run_llvm_mca { 70 | serde_json::json! { 71 | [{"id": LLVM_MCA_TOOL_ID}] 72 | } 73 | } else { 74 | serde_json::json! { 75 | [] 76 | } 77 | }; 78 | 79 | let http_request = http 80 | .post(format!( 81 | "https://godbolt.org/api/compiler/{}/compile", 82 | request.rustc 83 | )) 84 | .header(reqwest::header::ACCEPT, "application/json") // to make godbolt respond in JSON 85 | .json(&serde_json::json! { { 86 | "source": request.source_code, 87 | "options": { 88 | "userArguments": request.flags, 89 | "tools": tools, 90 | // "libraries": [{"id": "itoa", "version": "102"}], 91 | }, 92 | } }) 93 | .build()?; 94 | 95 | let response: GodboltResponse = http.execute(http_request).await?.json().await?; 96 | 97 | // TODO: use the extract_relevant_lines utility to strip stderr nicely 98 | Ok(Compilation { 99 | output: if request.run_llvm_mca { 100 | let text = response 101 | .tools 102 | .iter() 103 | .find(|tool| tool.id == LLVM_MCA_TOOL_ID) 104 | .map(|llvm_mca| llvm_mca.stdout.concatenate()) 105 | .ok_or(anyhow!("No llvm-mca result was sent by Godbolt"))?; 106 | // Strip junk 107 | text[..text.find("Instruction Info").unwrap_or(text.len())] 108 | .trim() 109 | .to_string() 110 | } else { 111 | response.asm.concatenate() 112 | }, 113 | stderr: response.stderr.concatenate(), 114 | }) 115 | } 116 | 117 | async fn save_to_shortlink(http: &reqwest::Client, req: &GodboltRequest<'_>) -> String { 118 | #[derive(serde::Deserialize)] 119 | struct GodboltShortenerResponse { 120 | url: String, 121 | } 122 | 123 | let tools = if req.run_llvm_mca { 124 | serde_json::json! { 125 | [{"id": LLVM_MCA_TOOL_ID}] 126 | } 127 | } else { 128 | serde_json::json! { 129 | [] 130 | } 131 | }; 132 | 133 | let request = http 134 | .post("https://godbolt.org/api/shortener") 135 | .json(&serde_json::json! { { 136 | "sessions": [{ 137 | "language": "rust", 138 | "source": req.source_code, 139 | "compilers": [{ 140 | "id": req.rustc, 141 | "options": req.flags, 142 | "tools": tools, 143 | }], 144 | }] 145 | } }); 146 | 147 | // Try block substitute 148 | let url = async move { 149 | Ok::<_, crate::Error>( 150 | request 151 | .send() 152 | .await? 153 | .json::() 154 | .await? 155 | .url, 156 | ) 157 | }; 158 | url.await.unwrap_or_else(|e| { 159 | warn!("failed to generate godbolt shortlink: {}", e); 160 | "failed to retrieve".to_owned() 161 | }) 162 | } 163 | 164 | #[derive(PartialEq, Clone, Copy)] 165 | #[allow(unused)] 166 | enum GodboltMode { 167 | Asm, 168 | LlvmIr, 169 | Mca, 170 | } 171 | 172 | fn note(no_mangle_added: bool) -> &'static str { 173 | if no_mangle_added { 174 | "" 175 | } else { 176 | "Note: only `pub fn` at file scope are shown" 177 | } 178 | } 179 | 180 | fn add_no_mangle(code: &mut String) -> bool { 181 | let mut no_mangle_added = false; 182 | if let Ok(file) = syn::parse_str::(code) { 183 | let mut spans = vec![]; 184 | for item in &file.items { 185 | let syn::Item::Fn(function) = item else { 186 | continue; 187 | }; 188 | let syn::Visibility::Public(_) = function.vis else { 189 | continue; 190 | }; 191 | 192 | // could check for existing `#[unsafe(no_mangle)]` attributes before adding it here 193 | spans.push(function.span()); 194 | no_mangle_added = true; 195 | } 196 | 197 | // iterate in reverse so that the indices dont get messed up 198 | for span in spans.iter().rev() { 199 | let range = span.byte_range(); 200 | code.insert_str(range.start, "#[unsafe(no_mangle)] "); 201 | } 202 | } 203 | no_mangle_added 204 | } 205 | 206 | async fn respond_codeblocks( 207 | ctx: Context<'_>, 208 | godbolt_result: Compilation, 209 | godbolt_request: GodboltRequest<'_>, 210 | lang: &'static str, 211 | note: &str, 212 | ) -> Result<(), Error> { 213 | match (godbolt_result.output.trim(), godbolt_result.stderr.trim()) { 214 | ("", "") => respond_codeblock(ctx, "", " ", note, &godbolt_request).await?, 215 | (output, "") => respond_codeblock(ctx, lang, output, note, &godbolt_request).await?, 216 | ("", errors) => { 217 | respond_codeblock(ctx, "ansi", errors, "Compilation failed.", &godbolt_request).await?; 218 | } 219 | ("", warnings) => respond_codeblock(ctx, "ansi", warnings, note, &godbolt_request).await?, 220 | (output, errors) => { 221 | ctx.say( 222 | crate::helpers::trim_text( 223 | &format!("```{lang}\n{output}``````ansi\n{errors}"), 224 | &format!("\n```{note}"), 225 | async { 226 | format!( 227 | "Output too large. Godbolt link: <{}>", 228 | save_to_shortlink(&ctx.data().http, &godbolt_request).await, 229 | ) 230 | }, 231 | ) 232 | .await, 233 | ) 234 | .await?; 235 | } 236 | } 237 | Ok(()) 238 | } 239 | 240 | async fn respond_codeblock( 241 | ctx: Context<'_>, 242 | codeblock_lang: &str, 243 | text: &str, 244 | note: &str, 245 | godbolt_request: &GodboltRequest<'_>, 246 | ) -> Result<(), Error> { 247 | ctx.say( 248 | crate::helpers::trim_text( 249 | &format!("```{codeblock_lang}\n{text}"), 250 | &format!("\n```{note}"), 251 | async { 252 | format!( 253 | "Output too large. Godbolt link: <{}>", 254 | save_to_shortlink(&ctx.data().http, godbolt_request).await, 255 | ) 256 | }, 257 | ) 258 | .await, 259 | ) 260 | .await?; 261 | Ok(()) 262 | } 263 | 264 | fn parse(args: &str) -> Result<(KeyValueArgs, String), CodeBlockError> { 265 | let mut map = HashMap::new(); 266 | let mut key = String::new(); 267 | let mut value = String::new(); 268 | // flag for in a key 269 | let mut k = true; 270 | let mut args = args.chars(); 271 | let mut tick_count = 0; 272 | // note: you cant put a backtick in an argument 273 | for ch in args.by_ref() { 274 | match ch { 275 | '`' => { 276 | tick_count += 1; 277 | break; 278 | } 279 | ' ' | '\n' => { 280 | map.insert(take(&mut key), take(&mut value)); 281 | k = true; 282 | } 283 | '=' if k => k = false, 284 | c if k => key.push(c), 285 | c => value.push(c), 286 | } 287 | } 288 | 289 | // note: language can be parsed, but is discarded here 290 | let mut parsed_lang = false; 291 | let mut code = String::new(); 292 | for ch in args { 293 | match ch { 294 | // ``` 295 | '`' if tick_count == 3 && !parsed_lang => return Err(CodeBlockError::default()), 296 | // closing 297 | '`' if tick_count == 3 && parsed_lang => break, 298 | '`' => tick_count += 1, 299 | // ```rust 300 | // ^^^^ 301 | '\n' if tick_count == 3 && !parsed_lang => parsed_lang = true, 302 | _ if tick_count == 3 && !parsed_lang => {} 303 | 304 | c => code.push(c), 305 | } 306 | } 307 | Ok((KeyValueArgs(map), code)) 308 | } 309 | 310 | /// View assembly using Godbolt 311 | /// 312 | /// Compile Rust code using . Full optimizations are applied unless \ 313 | /// overriden. 314 | /// ``` 315 | /// ?godbolt $($flags )* rustc={} ``​` 316 | /// pub fn your_function() { 317 | /// // Code 318 | /// } 319 | /// ``​` 320 | /// ``` 321 | /// Optional arguments: 322 | /// - `flags*`: flags to pass to rustc invocation. Defaults to ["-Copt-level=3", "--edition=2024"] 323 | /// - `rustc`: compiler version to invoke. Defaults to `nightly`. Possible values: `nightly`, `beta` or full version like `1.45.2` 324 | #[poise::command(prefix_command, category = "Godbolt", broadcast_typing, track_edits)] 325 | #[implicit_fn::implicit_fn] 326 | pub async fn godbolt(ctx: Context<'_>, #[rest] arguments: String) -> Result<(), Error> { 327 | let (params, mut code) = parse(&arguments)?; 328 | let no_mangle_added = add_no_mangle(&mut code); 329 | let hl = params 330 | .get("--emit") 331 | .map(match _ { 332 | "llvmir" => "llvm", 333 | "dep-info" | "link" | "metadata" | "obj" | "llvm-bc" => "", 334 | "mir" => "rust", 335 | _ => "x86asm", 336 | }) 337 | .or(params.get("--target").map(match _.split('-').next() { 338 | Some("aarch64") => "arm", 339 | Some(x) if x.starts_with("arm") => "arm", 340 | Some(x) if x.starts_with("mips") || x.starts_with("riscv") => "mips", 341 | Some("wasm32" | "wasm64") => "wasm", 342 | Some("x86_64" | _) => "x86asm", 343 | None => "", // ??? (0 valid targets here) 344 | })) 345 | .unwrap_or("x86asm"); 346 | let (rustc, flags) = rustc_id_and_flags(ctx.data(), ¶ms).await?; 347 | let godbolt_request = GodboltRequest { 348 | source_code: &code, 349 | rustc: &rustc, 350 | flags: &flags, 351 | run_llvm_mca: false, 352 | }; 353 | let godbolt_result = compile_rust_source(&ctx.data().http, &godbolt_request).await?; 354 | 355 | let note = note(no_mangle_added); 356 | respond_codeblocks(ctx, godbolt_result, godbolt_request, hl, note).await 357 | } 358 | 359 | /// Run performance analysis using llvm-mca 360 | /// 361 | /// Run the performance analysis tool llvm-mca using . Full optimizations \ 362 | /// are applied unless overriden. 363 | /// ``` 364 | /// ?mca $($flags )* rustc={} ``​` 365 | /// pub fn your_function() { 366 | /// // Code 367 | /// } 368 | /// ``​` 369 | /// ``` 370 | /// Optional arguments: 371 | /// - `flags*`: flags to pass to rustc invocation. Defaults to ["-Copt-level=3", "--edition=2024"] 372 | /// - `rustc`: compiler version to invoke. Defaults to `nightly`. Possible values: `nightly`, `beta` or full version like `1.45.2` 373 | #[poise::command(prefix_command, category = "Godbolt", broadcast_typing, track_edits)] 374 | pub async fn mca(ctx: Context<'_>, #[rest] arguments: String) -> Result<(), Error> { 375 | let (params, mut code) = parse(&arguments)?; 376 | let no_mangle_added = add_no_mangle(&mut code); 377 | let (rustc, flags) = rustc_id_and_flags(ctx.data(), ¶ms).await?; 378 | let godbolt_request = GodboltRequest { 379 | source_code: &code, 380 | rustc: &rustc, 381 | flags: &flags, 382 | run_llvm_mca: true, 383 | }; 384 | 385 | let godbolt_result = compile_rust_source(&ctx.data().http, &godbolt_request).await?; 386 | 387 | let note = note(no_mangle_added); 388 | respond_codeblocks(ctx, godbolt_result, godbolt_request, "rust", note).await 389 | } 390 | 391 | /// View LLVM IR using Godbolt 392 | /// 393 | /// Compile Rust code using and emits LLVM IR. Full optimizations \ 394 | /// are applied unless overriden. 395 | /// 396 | /// Equivalent to ?godbolt but with extra flags `--emit=llvm-ir -Cdebuginfo=0`. 397 | /// ``` 398 | /// ?llvmir $($flags )* rustc={} ``​` 399 | /// pub fn your_function() { 400 | /// // Code 401 | /// } 402 | /// ``​` 403 | /// ``` 404 | /// Optional arguments: 405 | /// - `flags*`: flags to pass to rustc invocation. Defaults to ["-Copt-level=3", "--edition=2024"] 406 | /// - `rustc`: compiler version to invoke. Defaults to `nightly`. Possible values: `nightly`, `beta` or full version like `1.45.2` 407 | #[poise::command(prefix_command, category = "Godbolt", broadcast_typing, track_edits)] 408 | pub async fn llvmir(ctx: Context<'_>, #[rest] arguments: String) -> Result<(), Error> { 409 | let (params, mut code) = parse(&arguments)?; 410 | let no_mangle_added = add_no_mangle(&mut code); 411 | let (rustc, flags) = rustc_id_and_flags(ctx.data(), ¶ms).await?; 412 | let godbolt_request = GodboltRequest { 413 | source_code: &code, 414 | rustc: &rustc, 415 | flags: &(flags + " --emit=llvm-ir -Cdebuginfo=0"), 416 | run_llvm_mca: false, 417 | }; 418 | let godbolt_result = compile_rust_source(&ctx.data().http, &godbolt_request).await?; 419 | 420 | let note = note(no_mangle_added); 421 | respond_codeblocks(ctx, godbolt_result, godbolt_request, "llvm", note).await 422 | } 423 | -------------------------------------------------------------------------------- /src/commands/godbolt/targets.rs: -------------------------------------------------------------------------------- 1 | use std::iter::once; 2 | 3 | use anyhow::{anyhow, Error}; 4 | use poise::serenity_prelude as serenity; 5 | use tracing::{error, info}; 6 | 7 | use crate::types::Context; 8 | use crate::types::Data; 9 | 10 | #[derive(Debug, Clone, serde::Deserialize)] 11 | #[serde(rename_all = "camelCase")] 12 | struct GodboltTarget { 13 | id: String, 14 | name: String, 15 | semver: String, 16 | instruction_set: String, 17 | } 18 | 19 | #[derive(Debug, Clone, serde::Deserialize)] 20 | struct GodboltLibraryVersion { 21 | #[allow(unused)] 22 | id: String, 23 | } 24 | 25 | #[derive(Debug, Clone, serde::Deserialize)] 26 | #[allow(unused)] 27 | struct GodboltLibrary { 28 | #[allow(unused)] 29 | id: String, 30 | #[allow(unused)] 31 | versions: Vec, 32 | } 33 | 34 | #[derive(Default, Debug)] 35 | pub struct GodboltMetadata { 36 | targets: Vec, 37 | #[allow(unused)] 38 | libraries: Vec, 39 | last_update_time: Option, 40 | } 41 | 42 | impl GodboltTarget { 43 | fn clean_request_data(&mut self) { 44 | // Some semvers get weird characters like `()` in them or spaces, we strip that out here 45 | self.semver = self 46 | .semver 47 | .chars() 48 | .filter(|char| char.is_alphanumeric() || matches!(char, '.' | '-' | '_')) 49 | .map(|char| char.to_ascii_lowercase()) 50 | .collect(); 51 | } 52 | } 53 | 54 | async fn update_godbolt_metadata(data: &Data) -> Result<(), Error> { 55 | let last_update_time = data.godbolt_metadata.lock().unwrap().last_update_time; 56 | let needs_update = if let Some(last_update_time) = last_update_time { 57 | // Get the time to wait between each update of the godbolt metadata 58 | let update_period = std::env::var("GODBOLT_UPDATE_DURATION") 59 | .ok() 60 | .and_then(|duration| duration.parse::().ok()) 61 | .map_or_else( 62 | // Currently set for 12 hours 63 | || std::time::Duration::from_secs(60 * 60 * 12), 64 | std::time::Duration::from_secs, 65 | ); 66 | 67 | let time_since_update = 68 | std::time::Instant::now().saturating_duration_since(last_update_time); 69 | let needs_update = time_since_update >= update_period; 70 | if needs_update { 71 | info!( 72 | "godbolt metadata was last updated {:#?} ago, updating it", 73 | time_since_update, 74 | ); 75 | } 76 | 77 | needs_update 78 | } else { 79 | info!("godbolt metadata hasn't yet been updated, fetching it"); 80 | 81 | true 82 | }; 83 | 84 | // If we should perform an update then do so 85 | if needs_update { 86 | let request = data 87 | .http 88 | .get("https://godbolt.org/api/compilers/rust") 89 | .header(reqwest::header::ACCEPT, "application/json"); 90 | let mut targets: Vec = request.send().await?.json().await?; 91 | // Clean up the data we've gotten from the request 92 | for target in &mut targets { 93 | target.clean_request_data(); 94 | if let Some(semver) = target.semver.strip_prefix("rustc ") { 95 | target.semver = semver.to_owned(); 96 | } 97 | } 98 | 99 | let request = data 100 | .http 101 | .get("https://godbolt.org/api/libraries/rust") 102 | .header(reqwest::header::ACCEPT, "application/json"); 103 | let libraries: Vec = request.send().await?.json().await?; 104 | 105 | info!( 106 | "updating godbolt metadata: {} targets, {} libraries", 107 | targets.len(), 108 | libraries.len() 109 | ); 110 | *data.godbolt_metadata.lock().unwrap() = GodboltMetadata { 111 | targets, 112 | libraries, 113 | last_update_time: Some(std::time::Instant::now()), 114 | }; 115 | } 116 | 117 | Ok(()) 118 | } 119 | 120 | pub async fn fetch_godbolt_metadata( 121 | data: &Data, 122 | ) -> impl std::ops::Deref + '_ { 123 | // If we encounter an error while updating the targets list, just log it 124 | if let Err(error) = update_godbolt_metadata(data).await { 125 | error!("failed to update godbolt metadata: {:?}", error); 126 | } 127 | 128 | data.godbolt_metadata.lock().unwrap() 129 | } 130 | 131 | // Generates godbolt-compatible rustc identifier and flags from command input 132 | // 133 | // Transforms human readable rustc version (e.g. "1.34.1") into compiler id on godbolt (e.g. "r1341") 134 | // Full list of version<->id can be obtained at https://godbolt.org/api/compilers/rust 135 | pub(crate) async fn rustc_id_and_flags( 136 | data: &Data, 137 | params: &poise::KeyValueArgs, 138 | ) -> Result<(String, String), Error> { 139 | let rustc = params.get("rustc").unwrap_or("nightly"); 140 | let target = fetch_godbolt_metadata(data) 141 | .await 142 | .targets 143 | .iter() 144 | .find(|target| target.semver == rustc.trim()) 145 | .cloned() 146 | .ok_or(anyhow!( 147 | "the `rustc` argument should be a version specifier like `nightly` `beta` or `1.45.2`. \ 148 | Run ?targets for a full list"))?; 149 | 150 | let opt_level = params.get("-Copt-level").unwrap_or("3"); 151 | let edition = params.get("--edition").unwrap_or("2024"); 152 | let flags = itertools::Itertools::intersperse(params 153 | .0 154 | .iter() 155 | .filter(|(k, _)| !matches!(k.as_str(), "rustc" | "-Copt-level" | "--edition")) 156 | .map(|(a, b)| format!("{a}={b}")) 157 | .chain(once(format!("-Copt-level={opt_level}"))) 158 | .chain(once(format!("--edition={edition}"))), " ".to_string()) 159 | // itertools was already imported by prost 160 | .collect::(); 161 | println!("{flags}"); 162 | 163 | Ok((target.id, flags)) 164 | } 165 | 166 | /// Used to rank godbolt compiler versions for listing them out 167 | #[derive(PartialEq, Eq, PartialOrd, Ord)] 168 | enum SemverRanking<'a> { 169 | Beta, 170 | Nightly, 171 | Compiler(&'a str), 172 | Semver(std::cmp::Reverse<(u16, u16, u16)>), 173 | } 174 | 175 | impl<'a> From<&'a str> for SemverRanking<'a> { 176 | fn from(semver: &'a str) -> Self { 177 | match semver { 178 | "beta" => Self::Beta, 179 | "nightly" => Self::Nightly, 180 | 181 | semver => { 182 | // Rustc versions are received in a `X.X.X` form, so we parse out 183 | // the major/minor/patch versions and then order them in *reverse* 184 | // order based on their version triple, this means that the most 185 | // recent (read: higher) versions will be at the top of the list 186 | let mut version_triple = semver.splitn(3, '.'); 187 | let version_triple = version_triple 188 | .next() 189 | .zip(version_triple.next()) 190 | .zip(version_triple.next()) 191 | .and_then(|((major, minor), patch)| { 192 | Some(( 193 | major.parse().ok()?, 194 | minor.parse().ok()?, 195 | patch.parse().ok()?, 196 | )) 197 | }); 198 | 199 | // If we successfully parsed out a semver tuple, return it 200 | if let Some((major, minor, patch)) = version_triple { 201 | Self::Semver(std::cmp::Reverse((major, minor, patch))) 202 | 203 | // Anything that doesn't fit the `X.X.X` format we treat as an alternative 204 | // compiler, we list these after beta & nightly but before the many canonical 205 | // rustc versions 206 | } else { 207 | Self::Compiler(semver) 208 | } 209 | } 210 | } 211 | } 212 | } 213 | 214 | /// Lists all available godbolt rustc targets 215 | #[poise::command(prefix_command, slash_command, broadcast_typing, category = "Godbolt")] 216 | pub async fn targets(ctx: Context<'_>) -> Result<(), Error> { 217 | let mut targets = fetch_godbolt_metadata(ctx.data()).await.targets.clone(); 218 | 219 | // Can't use sort_by_key because https://github.com/rust-lang/rust/issues/34162 220 | targets.sort_unstable_by(|lhs, rhs| { 221 | SemverRanking::from(&*lhs.semver).cmp(&SemverRanking::from(&*rhs.semver)) 222 | }); 223 | 224 | ctx.send( 225 | poise::CreateReply::default().embed( 226 | serenity::CreateEmbed::default() 227 | .title("Godbolt Targets") 228 | .fields(targets.into_iter().map(|target| { 229 | ( 230 | target.semver, 231 | format!("{} (runs on {})", target.name, target.instruction_set), 232 | true, 233 | ) 234 | })), 235 | ), 236 | ) 237 | .await?; 238 | 239 | Ok(()) 240 | } 241 | -------------------------------------------------------------------------------- /src/commands/man.rs: -------------------------------------------------------------------------------- 1 | use anyhow::bail; 2 | use anyhow::Result; 3 | use reqwest::header; 4 | 5 | use crate::serenity; 6 | use crate::types::Context; 7 | 8 | const USER_AGENT: &str = "kangalioo/rustbot"; 9 | 10 | #[poise::command( 11 | prefix_command, 12 | slash_command, 13 | broadcast_typing, 14 | category = "Utilities" 15 | )] 16 | pub async fn man( 17 | ctx: Context<'_>, 18 | #[description = "Section of the man page"] section: Option, 19 | #[description = "Name of the man page"] man_page: String, 20 | ) -> Result<()> { 21 | let section = section.unwrap_or_else(|| "1".to_owned()); 22 | 23 | // Make sure that the section is a valid number 24 | if section.parse::().is_err() { 25 | bail!("Invalid section number"); 26 | } 27 | 28 | let mut url = format!("https://manpages.debian.org/{section}/{man_page}"); 29 | 30 | if let Ok(response) = ctx 31 | .data() 32 | .http 33 | .get(&url) 34 | .header(header::USER_AGENT, USER_AGENT) 35 | .send() 36 | .await 37 | { 38 | if response.status() == 404 { 39 | ctx.say("Man page not found.").await?; 40 | return Ok(()); 41 | } 42 | } else { 43 | ctx.say("Failed to fetch man page.").await?; 44 | return Ok(()); 45 | } 46 | 47 | url.push_str(".html"); 48 | 49 | ctx.send( 50 | poise::CreateReply::default().embed( 51 | serenity::CreateEmbed::new() 52 | .title(format!("man {man_page}({section})")) 53 | .description(format!("View the man page for `{man_page}` on the web")) 54 | .url(&url) 55 | .color(crate::types::EMBED_COLOR) 56 | .footer(serenity::CreateEmbedFooter::new( 57 | "Powered by manpages.debian.org", 58 | )) 59 | .thumbnail("https://www.debian.org/logos/openlogo-nd-100.jpg") 60 | .field("Section", §ion, true) 61 | .field("Page", &man_page, true) 62 | .timestamp(serenity::Timestamp::now()), 63 | ), 64 | ) 65 | .await?; 66 | 67 | Ok(()) 68 | } 69 | -------------------------------------------------------------------------------- /src/commands/modmail.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{anyhow, Error}; 2 | use poise::serenity_prelude as serenity; 3 | use poise::serenity_prelude::{EditThread, GuildChannel, Mentionable, UserId}; 4 | use rand::Rng; 5 | use tracing::{debug, info}; 6 | 7 | use crate::types::{Context, Data}; 8 | 9 | /// Opens a modmail thread for a message. To use, right-click the message that 10 | /// you want to report, then go to "Apps" > "Open Modmail". 11 | #[poise::command( 12 | ephemeral, 13 | context_menu_command = "Open Modmail", 14 | hide_in_help, 15 | category = "Modmail" 16 | )] 17 | pub async fn modmail_context_menu_for_message( 18 | ctx: Context<'_>, 19 | #[description = "Message to automatically link when opening a modmail"] 20 | message: serenity::Message, 21 | ) -> Result<(), Error> { 22 | let message = format!( 23 | "Message reported: {}\n\nMessage contents:\n\n{}", 24 | message.id.link(ctx.channel_id(), ctx.guild_id()), 25 | message.content_safe(ctx) 26 | ); 27 | let modmail = create_modmail_thread(ctx, message, ctx.data(), ctx.author().id).await?; 28 | ctx.say(format!( 29 | "Successfully sent your message to the moderators. Check out your modmail thread here: {}", 30 | modmail.mention() 31 | )) 32 | .await?; 33 | Ok(()) 34 | } 35 | 36 | /// Opens a modmail thread for a guild member. To use, right-click the member 37 | /// that you want to report, then go to "Apps" > "Open Modmail". 38 | #[poise::command( 39 | ephemeral, 40 | context_menu_command = "Open Modmail", 41 | hide_in_help, 42 | category = "Modmail" 43 | )] 44 | pub async fn modmail_context_menu_for_user( 45 | ctx: Context<'_>, 46 | #[description = "User to automatically link when opening a modmail"] user: serenity::User, 47 | ) -> Result<(), Error> { 48 | let message = format!("User reported:\n{}\n{}\n\nPlease provide additional information about the user being reported.", user.id, user.name); 49 | let modmail = create_modmail_thread(ctx, message, ctx.data(), ctx.author().id).await?; 50 | ctx.say(format!( 51 | "Successfully sent your message to the moderators. Check out your modmail thread here: {}", 52 | modmail.mention() 53 | )) 54 | .await?; 55 | Ok(()) 56 | } 57 | 58 | /// Send a private message to the moderators of the server. 59 | /// 60 | /// Call this command in a channel when someone might be breaking the rules, for example by being \ 61 | /// very rude, or starting discussions about divisive topics like politics and religion. Nobody \ 62 | /// will see that you invoked this command. 63 | /// 64 | /// You can also use this command whenever you want to ask private questions to the moderator team, 65 | /// open ban appeals, and generally anything that you need help with. 66 | /// 67 | /// Your message, along with a link to the channel and its most recent message, will show up in a 68 | /// dedicated modmail channel for moderators, and it allows them to deal with it much faster than if 69 | /// you were to DM a potentially AFK moderator. 70 | /// 71 | /// You can still always ping the Moderator role if you're comfortable doing so. 72 | #[poise::command(prefix_command, slash_command, ephemeral, category = "Modmail")] 73 | pub async fn modmail( 74 | ctx: Context<'_>, 75 | #[description = "What would you like to say?"] user_message: String, 76 | ) -> Result<(), Error> { 77 | let message = format!( 78 | "{}\n\nSent from {}", 79 | user_message, 80 | ctx.channel_id().mention() 81 | ); 82 | let modmail = create_modmail_thread(ctx, message, ctx.data(), ctx.author().id).await?; 83 | ctx.say(format!( 84 | "Successfully sent your message to the moderators. Check out your modmail thread here: {}", 85 | modmail.mention() 86 | )) 87 | .await?; 88 | Ok(()) 89 | } 90 | 91 | pub async fn load_or_create_modmail_message( 92 | http: impl serenity::CacheHttp, 93 | data: &Data, 94 | ) -> Result<(), Error> { 95 | // Do nothing if message already exists in cache 96 | if data.modmail_message.read().await.clone().is_some() { 97 | debug!("Modmail message already exists on data cache."); 98 | return Ok(()); 99 | } 100 | 101 | // Fetch modmail guild channel 102 | let modmail_guild_channel = data 103 | .modmail_channel_id 104 | .to_channel(&http) 105 | .await 106 | .map_err(|e| anyhow!(e).context("Cannot enter modmail channel"))? 107 | .guild() 108 | .ok_or(anyhow!("This command can only be used in a guild"))?; 109 | 110 | // Fetch the report message itself 111 | let open_report_message = modmail_guild_channel 112 | .messages(&http, serenity::GetMessages::new().limit(1)) 113 | .await? 114 | .first() 115 | .cloned(); 116 | 117 | let message = if let Some(desired_message) = open_report_message { 118 | // If it exists, return it 119 | desired_message 120 | } else { 121 | // If it doesn't exist, create one and return it 122 | debug!("Creating new modmail message"); 123 | modmail_guild_channel 124 | .send_message( 125 | &http, 126 | serenity::CreateMessage::new() 127 | .content("\ 128 | This is the Modmail channel. In here, you're able to create modmail reports to reach out to the Moderators about things such as reporting rule breaking, or asking a private question. 129 | 130 | To open a ticket, either right click the offending message and then \"Apps > Report to Modmail\". Alternatively, click the \"Create new Modmail\" button below (soon). 131 | 132 | When creating a rule-breaking report please give a brief description of what is happening along with relevant information, such as members involved, links to offending messages, and a summary of the situation. 133 | 134 | The modmail will materialize itself as a private thread under this channel with a random ID. You will be pinged in the thread once the report is opened. Once the report is dealt with, it will be archived") 135 | .button( 136 | serenity::CreateButton::new("rplcs_create_new_modmail") 137 | .label("Create New Modmail") 138 | .emoji(serenity::ReactionType::Unicode("📩".to_string())) 139 | .style(serenity::ButtonStyle::Primary), 140 | ), 141 | ) 142 | .await? 143 | }; 144 | 145 | // Cache the message in the Data struct 146 | store_message(data, message).await; 147 | 148 | Ok(()) 149 | } 150 | 151 | /// It's important to keep this in a function because we're dealing with lifetimes and guard drops. 152 | async fn store_message(data: &Data, message: serenity::Message) { 153 | info!("Storing modlog message on cache."); 154 | let mut rwguard = data.modmail_message.write().await; 155 | rwguard.get_or_insert(message); 156 | } 157 | 158 | pub async fn create_modmail_thread( 159 | http: impl serenity::CacheHttp, 160 | user_message: impl Into, 161 | data: &Data, 162 | user_id: UserId, 163 | ) -> Result { 164 | load_or_create_modmail_message(&http, data).await?; 165 | 166 | let modmail_message = data 167 | .modmail_message 168 | .read() 169 | .await 170 | .clone() 171 | .ok_or(anyhow!("Modmail message somehow ceased to exist"))?; 172 | 173 | let modmail_channel = modmail_message 174 | .channel(&http) 175 | .await? 176 | .guild() 177 | .ok_or(anyhow!("Modmail channel is not in a guild!"))?; 178 | 179 | let modmail_name = format!("Modmail #{}", rand::rng().random_range(1..10000)); 180 | 181 | let mut modmail_thread = modmail_channel 182 | .create_thread( 183 | &http, 184 | serenity::CreateThread::new(modmail_name).kind(serenity::ChannelType::PrivateThread), 185 | ) 186 | .await?; 187 | 188 | // disallow users from inviting others to modmail threads 189 | modmail_thread 190 | .edit_thread(&http, EditThread::new().invitable(false)) 191 | .await?; 192 | 193 | let thread_message_content = format!( 194 | "Hey {}, {} needs help with the following:\n> {}", 195 | data.mod_role_id.mention(), 196 | user_id.mention(), 197 | user_message.into() 198 | ); 199 | 200 | modmail_thread 201 | .send_message( 202 | &http, 203 | serenity::CreateMessage::new() 204 | .content(thread_message_content) 205 | .allowed_mentions( 206 | serenity::CreateAllowedMentions::new() 207 | .users([user_id]) 208 | .roles([data.mod_role_id]), 209 | ), 210 | ) 211 | .await?; 212 | 213 | Ok(modmail_thread) 214 | } 215 | -------------------------------------------------------------------------------- /src/commands/playground.rs: -------------------------------------------------------------------------------- 1 | //! run rust code on the rust-lang playground 2 | 3 | pub use microbench::*; 4 | pub use misc_commands::*; 5 | pub use play_eval::*; 6 | pub use procmacro::*; 7 | 8 | mod api; 9 | mod microbench; 10 | mod misc_commands; 11 | mod play_eval; 12 | mod procmacro; 13 | mod util; 14 | -------------------------------------------------------------------------------- /src/commands/playground/api.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | use std::str::FromStr; 3 | 4 | use anyhow::{anyhow, bail, Error}; 5 | use reqwest::header; 6 | use serde::{Deserialize, Deserializer, Serialize}; 7 | use tracing::info; 8 | 9 | use crate::types::Context; 10 | 11 | pub struct CommandFlags { 12 | pub channel: Channel, 13 | pub mode: Mode, 14 | pub edition: Edition, 15 | pub warn: bool, 16 | pub run: bool, 17 | pub aliasing_model: AliasingModel, 18 | } 19 | 20 | #[derive(Debug, Serialize)] 21 | pub struct PlaygroundRequest<'a> { 22 | pub channel: Channel, 23 | pub edition: Edition, 24 | pub code: &'a str, 25 | #[serde(rename = "crateType")] 26 | pub crate_type: CrateType, 27 | pub mode: Mode, 28 | pub tests: bool, 29 | } 30 | 31 | #[derive(Debug, Serialize)] 32 | pub struct MiriRequest<'a> { 33 | pub edition: Edition, 34 | #[serde(rename = "aliasingModel")] 35 | pub aliasing_model: AliasingModel, 36 | pub code: &'a str, 37 | } 38 | 39 | #[derive(Debug, Serialize)] 40 | pub struct MacroExpansionRequest<'a> { 41 | pub edition: Edition, 42 | pub code: &'a str, 43 | } 44 | 45 | #[derive(Debug, Serialize)] 46 | pub struct ClippyRequest<'a> { 47 | pub edition: Edition, 48 | #[serde(rename = "crateType")] 49 | pub crate_type: CrateType, 50 | pub code: &'a str, 51 | } 52 | 53 | #[derive(Debug, Serialize)] 54 | pub struct FormatRequest<'a> { 55 | pub code: &'a str, 56 | pub edition: Edition, 57 | } 58 | 59 | #[derive(Debug, Deserialize)] 60 | pub struct FormatResponse { 61 | pub success: bool, 62 | pub code: String, 63 | pub stderr: String, 64 | } 65 | 66 | #[derive(Debug, Serialize)] 67 | #[serde(rename_all = "camelCase")] 68 | pub struct CompileRequest<'a> { 69 | pub assembly_flavor: AssemblyFlavour, 70 | pub backtrace: bool, 71 | pub channel: Channel, 72 | pub code: &'a str, 73 | pub crate_type: CrateType, 74 | pub demangle_assembly: DemangleAssembly, 75 | pub edition: Edition, 76 | pub mode: Mode, 77 | pub process_assembly: ProcessAssembly, 78 | pub target: CompileTarget, 79 | pub tests: bool, 80 | } 81 | 82 | #[derive(Debug, Default, Serialize)] 83 | #[serde(rename_all = "snake_case")] 84 | pub enum AssemblyFlavour { 85 | #[default] 86 | Intel, 87 | #[allow(dead_code)] 88 | Att, 89 | } 90 | 91 | #[derive(Debug, Default, Serialize)] 92 | #[serde(rename_all = "snake_case")] 93 | pub enum DemangleAssembly { 94 | #[default] 95 | Demangle, 96 | #[allow(dead_code)] 97 | Mangle, 98 | } 99 | 100 | #[derive(Debug, Default, Serialize)] 101 | #[serde(rename_all = "snake_case")] 102 | pub enum ProcessAssembly { 103 | #[default] 104 | Filter, 105 | #[allow(dead_code)] 106 | Raw, 107 | } 108 | 109 | #[derive(Debug, Serialize)] 110 | #[serde(rename_all = "snake_case")] 111 | #[allow(unused)] 112 | pub enum CompileTarget { 113 | Mir, 114 | } 115 | 116 | #[allow(unused)] 117 | pub type CompileResponse = FormatResponse; 118 | 119 | #[derive(Debug, Clone, Copy, Serialize)] 120 | #[serde(rename_all = "snake_case")] 121 | #[allow(unused)] 122 | pub enum Channel { 123 | Stable, 124 | Beta, 125 | Nightly, 126 | } 127 | 128 | impl FromStr for Channel { 129 | type Err = Error; 130 | 131 | fn from_str(s: &str) -> Result { 132 | match s { 133 | "stable" => Ok(Channel::Stable), 134 | "beta" => Ok(Channel::Beta), 135 | "nightly" => Ok(Channel::Nightly), 136 | _ => bail!("invalid release channel `{}`", s), 137 | } 138 | } 139 | } 140 | 141 | #[derive(Debug, Clone, Copy, Serialize)] 142 | pub enum Edition { 143 | #[serde(rename = "2015")] 144 | E2015, 145 | #[serde(rename = "2018")] 146 | E2018, 147 | #[serde(rename = "2021")] 148 | E2021, 149 | #[serde(rename = "2024")] 150 | E2024, 151 | } 152 | 153 | impl FromStr for Edition { 154 | type Err = Error; 155 | 156 | fn from_str(s: &str) -> Result { 157 | match s { 158 | "2015" => Ok(Edition::E2015), 159 | "2018" => Ok(Edition::E2018), 160 | "2021" => Ok(Edition::E2021), 161 | "2024" => Ok(Edition::E2024), 162 | _ => bail!("invalid edition `{}`", s), 163 | } 164 | } 165 | } 166 | 167 | #[derive(Debug, Clone, Copy, Serialize)] 168 | #[allow(unused)] 169 | pub enum CrateType { 170 | #[serde(rename = "bin")] 171 | Binary, 172 | #[serde(rename = "lib")] 173 | Library, 174 | } 175 | 176 | #[derive(Debug, Clone, Copy, Serialize)] 177 | #[serde(rename_all = "snake_case")] 178 | pub enum Mode { 179 | Debug, 180 | Release, 181 | } 182 | 183 | impl FromStr for Mode { 184 | type Err = Error; 185 | 186 | fn from_str(s: &str) -> Result { 187 | match s { 188 | "debug" => Ok(Mode::Debug), 189 | "release" => Ok(Mode::Release), 190 | _ => bail!("invalid compilation mode `{}`", s), 191 | } 192 | } 193 | } 194 | 195 | #[derive(Debug, Clone, Copy, Serialize)] 196 | #[serde(rename_all = "snake_case")] 197 | pub enum AliasingModel { 198 | Stacked, 199 | Tree, 200 | } 201 | 202 | impl FromStr for AliasingModel { 203 | type Err = Error; 204 | 205 | fn from_str(s: &str) -> Result { 206 | Ok(match s { 207 | "stacked" => AliasingModel::Stacked, 208 | "tree" => AliasingModel::Tree, 209 | _ => bail!("invalid aliasing model `{}`", s), 210 | }) 211 | } 212 | } 213 | 214 | #[derive(Debug)] 215 | pub struct PlayResult { 216 | pub success: bool, 217 | pub stdout: String, 218 | pub stderr: String, 219 | } 220 | 221 | impl<'de> Deserialize<'de> for PlayResult { 222 | fn deserialize>(deserializer: D) -> Result { 223 | // The playground occasionally sends just a single "error" field, for example with 224 | // `loop{println!("a")}`. We put the error into the stderr field 225 | 226 | #[derive(Deserialize)] 227 | #[serde(untagged)] 228 | pub enum RawPlayResponse { 229 | Err { 230 | error: String, 231 | }, 232 | Ok { 233 | success: bool, 234 | stdout: String, 235 | stderr: String, 236 | }, 237 | } 238 | 239 | Ok(match RawPlayResponse::deserialize(deserializer)? { 240 | RawPlayResponse::Ok { 241 | success, 242 | stdout, 243 | stderr, 244 | } => PlayResult { 245 | success, 246 | stdout, 247 | stderr, 248 | }, 249 | RawPlayResponse::Err { error } => PlayResult { 250 | success: false, 251 | stdout: String::new(), 252 | stderr: error, 253 | }, 254 | }) 255 | } 256 | } 257 | 258 | /// Returns a gist ID 259 | pub async fn post_gist(ctx: Context<'_>, code: &str) -> Result { 260 | let mut payload = HashMap::new(); 261 | payload.insert("code", code); 262 | 263 | let resp = ctx 264 | .data() 265 | .http 266 | .post("https://play.rust-lang.org/meta/gist/") 267 | .header(header::REFERER, "https://discord.gg/rust-lang-community") 268 | .json(&payload) 269 | .send() 270 | .await?; 271 | 272 | let mut resp: HashMap = resp.json().await?; 273 | info!("gist response: {:?}", resp); 274 | 275 | let gist_id = resp.remove("id").ok_or(anyhow!("no gist found"))?; 276 | Ok(gist_id) 277 | } 278 | 279 | pub fn url_from_gist(flags: &CommandFlags, gist_id: &str) -> String { 280 | format!( 281 | "https://play.rust-lang.org/?version={}&mode={}&edition={}&gist={}", 282 | match flags.channel { 283 | Channel::Nightly => "nightly", 284 | Channel::Beta => "beta", 285 | Channel::Stable => "stable", 286 | }, 287 | match flags.mode { 288 | Mode::Debug => "debug", 289 | Mode::Release => "release", 290 | }, 291 | match flags.edition { 292 | Edition::E2015 => "2015", 293 | Edition::E2018 => "2018", 294 | Edition::E2021 => "2021", 295 | Edition::E2024 => "2024", 296 | }, 297 | gist_id 298 | ) 299 | } 300 | 301 | pub async fn apply_online_rustfmt( 302 | ctx: Context<'_>, 303 | code: &str, 304 | edition: Edition, 305 | ) -> Result { 306 | let result = ctx 307 | .data() 308 | .http 309 | .post("https://play.rust-lang.org/format") 310 | .json(&FormatRequest { code, edition }) 311 | .send() 312 | .await? 313 | .json::() 314 | .await?; 315 | 316 | Ok(PlayResult { 317 | success: result.success, 318 | stdout: result.code, 319 | stderr: result.stderr, 320 | }) 321 | } 322 | -------------------------------------------------------------------------------- /src/commands/playground/microbench.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use core::fmt::Write as _; 3 | use syn::{parse_file, Item, ItemFn, Visibility}; 4 | 5 | use crate::types::Context; 6 | 7 | use super::{ 8 | api::{CrateType, Mode, PlayResult, PlaygroundRequest}, 9 | util::{ 10 | format_play_eval_stderr, generic_help, hoise_crate_attributes, parse_flags, send_reply, 11 | stub_message, GenericHelp, 12 | }, 13 | }; 14 | 15 | const BENCH_FUNCTION: &str = r#" 16 | fn bench(functions: &[(&str, fn())]) { 17 | const CHUNK_SIZE: usize = 1000; 18 | 19 | // Warm up 20 | for (_, function) in functions.iter() { 21 | for _ in 0..CHUNK_SIZE { 22 | (function)(); 23 | } 24 | } 25 | 26 | let mut functions_chunk_times = functions.iter().map(|_| Vec::new()).collect::>(); 27 | 28 | let start = std::time::Instant::now(); 29 | while (std::time::Instant::now() - start).as_secs() < 5 { 30 | for (chunk_times, (_, function)) in functions_chunk_times.iter_mut().zip(functions) { 31 | let start = std::time::Instant::now(); 32 | for _ in 0..CHUNK_SIZE { 33 | (function)(); 34 | } 35 | chunk_times.push((std::time::Instant::now() - start).as_secs_f64() / CHUNK_SIZE as f64); 36 | } 37 | } 38 | 39 | for (chunk_times, (function_name, _)) in functions_chunk_times.iter().zip(functions) { 40 | let mean_time: f64 = chunk_times.iter().sum::() / chunk_times.len() as f64; 41 | 42 | let mut sum_of_squared_deviations = 0.0; 43 | let mut n = 0; 44 | for &time in chunk_times { 45 | // Filter out outliers (there are some crazy outliers, I've checked) 46 | if time < mean_time * 3.0 { 47 | sum_of_squared_deviations += (time - mean_time).powi(2); 48 | n += 1; 49 | } 50 | } 51 | let standard_deviation = f64::sqrt(sum_of_squared_deviations / n as f64); 52 | 53 | println!( 54 | "{}: {:.1}ns ± {:.1}", 55 | function_name, 56 | mean_time * 1_000_000_000.0, 57 | standard_deviation * 1_000_000_000.0, 58 | ); 59 | } 60 | }"#; 61 | 62 | /// Benchmark small snippets of code 63 | #[poise::command( 64 | prefix_command, 65 | track_edits, 66 | help_text_fn = "microbench_help", 67 | category = "Playground" 68 | )] 69 | pub async fn microbench( 70 | ctx: Context<'_>, 71 | flags: poise::KeyValueArgs, 72 | code: poise::CodeBlock, 73 | ) -> Result<(), Error> { 74 | ctx.say(stub_message(ctx)).await?; 75 | 76 | let user_code = &code.code; 77 | let black_box_hint = !user_code.contains("black_box"); 78 | 79 | // insert convenience import for users 80 | let after_crate_attrs = "#[allow(unused_imports)] use std::hint::black_box;\n"; 81 | 82 | let pub_fn_names: Vec = extract_pub_fn_names_from_user_code(user_code); 83 | match pub_fn_names.len() { 84 | 0 => { 85 | ctx.say("No public functions (`pub fn`) found for benchmarking :thinking:") 86 | .await?; 87 | return Ok(()); 88 | } 89 | 1 => { 90 | ctx.say("Please include multiple functions. Times are not comparable across runs") 91 | .await?; 92 | return Ok(()); 93 | } 94 | _ => {} 95 | } 96 | 97 | // insert this after user code 98 | let mut after_code = BENCH_FUNCTION.to_owned(); 99 | after_code += "fn main() {\nbench(&["; 100 | for function_name in pub_fn_names { 101 | writeln!(after_code, "(\"{function_name}\", {function_name}),") 102 | .expect("Writing to a String should never fail"); 103 | } 104 | after_code += "]);\n}\n"; 105 | 106 | // final assembled code 107 | let code = hoise_crate_attributes(user_code, after_crate_attrs, &after_code); 108 | 109 | let (flags, mut flag_parse_errors) = parse_flags(flags); 110 | let mut result: PlayResult = ctx 111 | .data() 112 | .http 113 | .post("https://play.rust-lang.org/execute") 114 | .json(&PlaygroundRequest { 115 | code: &code, 116 | channel: flags.channel, 117 | crate_type: CrateType::Binary, 118 | edition: flags.edition, 119 | mode: Mode::Release, // benchmarks on debug don't make sense 120 | tests: false, 121 | }) 122 | .send() 123 | .await? 124 | .json() 125 | .await?; 126 | 127 | result.stderr = format_play_eval_stderr(&result.stderr, flags.warn); 128 | 129 | if black_box_hint { 130 | flag_parse_errors += 131 | "Hint: use the black_box function to prevent computations from being optimized out\n"; 132 | } 133 | send_reply(ctx, result, &code, &flags, &flag_parse_errors).await 134 | } 135 | 136 | #[must_use] 137 | pub fn microbench_help() -> String { 138 | generic_help(GenericHelp { 139 | command: "microbench", 140 | desc: "\ 141 | Benchmarks small snippets of code by running them repeatedly. Public functions \ 142 | are run in blocks of 1000 repetitions in a cycle until 5 seconds have \ 143 | passed. Measurements are averaged and standard deviation is calculated for each 144 | 145 | Use the `std::hint::black_box` function, which is already imported, to wrap results of \ 146 | computations that shouldn't be optimized out. Also wrap computation inputs in `black_box(...)` \ 147 | that should be opaque to the optimizer: `number * 2` produces optimized integer doubling assembly while \ 148 | `number * black_box(2)` produces a generic integer multiplication instruction", 149 | mode_and_channel: false, 150 | warn: true, 151 | run: false, 152 | aliasing_model: false, 153 | example_code: " 154 | pub fn add() { 155 | black_box(black_box(42.0) + black_box(99.0)); 156 | } 157 | pub fn mul() { 158 | black_box(black_box(42.0) * black_box(99.0)); 159 | } 160 | ", 161 | }) 162 | } 163 | 164 | fn extract_pub_fn_names_from_user_code(code: &str) -> Vec { 165 | let Ok(file) = parse_file(code) else { 166 | return vec![]; 167 | }; 168 | 169 | file.items 170 | .iter() 171 | .filter_map(|item| { 172 | if let Item::Fn(ItemFn { vis, sig, .. }) = item { 173 | if matches!(vis, Visibility::Public(_)) { 174 | return Some(sig.ident.to_string()); 175 | } 176 | } 177 | None 178 | }) 179 | .collect() 180 | } 181 | -------------------------------------------------------------------------------- /src/commands/playground/misc_commands.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use anyhow::Error; 4 | use tracing::warn; 5 | 6 | use crate::types::Context; 7 | 8 | use super::{ 9 | api::{ 10 | apply_online_rustfmt, ClippyRequest, CrateType, MacroExpansionRequest, MiriRequest, 11 | PlayResult, 12 | }, 13 | util::{ 14 | extract_relevant_lines, generic_help, maybe_wrap, maybe_wrapped, parse_flags, send_reply, 15 | strip_fn_main_boilerplate_from_formatted, stub_message, GenericHelp, ResultHandling, 16 | }, 17 | }; 18 | 19 | /// Run code and detect undefined behavior using Miri 20 | #[poise::command( 21 | prefix_command, 22 | track_edits, 23 | help_text_fn = "miri_help", 24 | category = "Playground" 25 | )] 26 | pub async fn miri( 27 | ctx: Context<'_>, 28 | flags: poise::KeyValueArgs, 29 | code: poise::CodeBlock, 30 | ) -> Result<(), Error> { 31 | ctx.say(stub_message(ctx)).await?; 32 | let code = &maybe_wrapped( 33 | &code.code, 34 | ResultHandling::Discard, 35 | ctx.prefix().contains("Sweat"), 36 | false, 37 | ); 38 | let (flags, flag_parse_errors) = parse_flags(flags); 39 | 40 | let mut result: PlayResult = ctx 41 | .data() 42 | .http 43 | .post("https://play.rust-lang.org/miri") 44 | .json(&MiriRequest { 45 | code, 46 | edition: flags.edition, 47 | aliasing_model: flags.aliasing_model, 48 | }) 49 | .send() 50 | .await? 51 | .json() 52 | .await?; 53 | 54 | result.stderr = extract_relevant_lines( 55 | &result.stderr, 56 | &["Running `/playground"], 57 | &["error: aborting"], 58 | ) 59 | .to_owned(); 60 | 61 | send_reply(ctx, result, code, &flags, &flag_parse_errors).await 62 | } 63 | 64 | #[must_use] 65 | pub fn miri_help() -> String { 66 | generic_help(GenericHelp { 67 | command: "miri", 68 | desc: "Execute this program in the Miri interpreter to detect certain cases of undefined \ 69 | behavior (like out-of-bounds memory access)", 70 | mode_and_channel: false, 71 | // Playgrounds sends miri warnings/errors and output in the same field so we can't filter 72 | // warnings out 73 | warn: false, 74 | aliasing_model: true, 75 | run: false, 76 | example_code: "code", 77 | }) 78 | } 79 | 80 | /// Expand macros to their raw desugared form 81 | #[poise::command( 82 | prefix_command, 83 | track_edits, 84 | help_text_fn = "expand_help", 85 | category = "Playground" 86 | )] 87 | pub async fn expand( 88 | ctx: Context<'_>, 89 | flags: poise::KeyValueArgs, 90 | code: poise::CodeBlock, 91 | ) -> Result<(), Error> { 92 | ctx.say(stub_message(ctx)).await?; 93 | 94 | let code = maybe_wrap(&code.code, ResultHandling::None); 95 | let was_fn_main_wrapped = matches!(code, Cow::Owned(_)); 96 | let (flags, flag_parse_errors) = parse_flags(flags); 97 | 98 | let mut result: PlayResult = ctx 99 | .data() 100 | .http 101 | .post("https://play.rust-lang.org/macro-expansion") 102 | .json(&MacroExpansionRequest { 103 | code: &code, 104 | edition: flags.edition, 105 | }) 106 | .send() 107 | .await? 108 | .json() 109 | .await?; 110 | 111 | result.stderr = extract_relevant_lines( 112 | &result.stderr, 113 | &["Finished ", "Compiling playground"], 114 | &["error: aborting"], 115 | ) 116 | .to_owned(); 117 | 118 | if result.success { 119 | match apply_online_rustfmt(ctx, &result.stdout, flags.edition).await { 120 | Ok(PlayResult { success: true, stdout, .. }) => result.stdout = stdout, 121 | Ok(PlayResult { success: false, stderr, .. }) => warn!("Huh, rustfmt failed even though this code successfully passed through macro expansion before: {}", stderr), 122 | Err(e) => warn!("Couldn't run rustfmt: {}", e), 123 | } 124 | } 125 | if was_fn_main_wrapped { 126 | result.stdout = strip_fn_main_boilerplate_from_formatted(&result.stdout); 127 | } 128 | 129 | send_reply(ctx, result, &code, &flags, &flag_parse_errors).await 130 | } 131 | 132 | #[must_use] 133 | pub fn expand_help() -> String { 134 | generic_help(GenericHelp { 135 | command: "expand", 136 | desc: "Expand macros to their raw desugared form", 137 | mode_and_channel: false, 138 | warn: false, 139 | run: false, 140 | aliasing_model: false, 141 | example_code: "code", 142 | }) 143 | } 144 | 145 | /// Catch common mistakes using the Clippy linter 146 | #[poise::command( 147 | prefix_command, 148 | track_edits, 149 | help_text_fn = "clippy_help", 150 | category = "Playground" 151 | )] 152 | pub async fn clippy( 153 | ctx: Context<'_>, 154 | flags: poise::KeyValueArgs, 155 | code: poise::CodeBlock, 156 | ) -> Result<(), Error> { 157 | ctx.say(stub_message(ctx)).await?; 158 | 159 | let code = &format!( 160 | // dead_code: https://github.com/kangalioo/rustbot/issues/44 161 | // let_unit_value: silence warning about `let _ = { ... }` wrapper that swallows return val 162 | "#![allow(dead_code, clippy::let_unit_value)] {}", 163 | maybe_wrapped( 164 | &code.code, 165 | ResultHandling::Discard, 166 | ctx.prefix().contains("Sweat"), 167 | false, 168 | ) 169 | ); 170 | let (flags, flag_parse_errors) = parse_flags(flags); 171 | 172 | let mut result: PlayResult = ctx 173 | .data() 174 | .http 175 | .post("https://play.rust-lang.org/clippy") 176 | .json(&ClippyRequest { 177 | code, 178 | edition: flags.edition, 179 | crate_type: CrateType::Binary, 180 | }) 181 | .send() 182 | .await? 183 | .json() 184 | .await?; 185 | 186 | result.stderr = extract_relevant_lines( 187 | &result.stderr, 188 | &["Checking playground", "Running `/playground"], 189 | &[ 190 | "error: aborting", 191 | "1 warning emitted", 192 | "warnings emitted", 193 | "Finished ", 194 | ], 195 | ) 196 | .to_owned(); 197 | 198 | send_reply(ctx, result, code, &flags, &flag_parse_errors).await 199 | } 200 | 201 | #[must_use] 202 | pub fn clippy_help() -> String { 203 | generic_help(GenericHelp { 204 | command: "clippy", 205 | desc: "Catch common mistakes and improve the code using the Clippy linter", 206 | mode_and_channel: false, 207 | warn: false, 208 | run: false, 209 | aliasing_model: false, 210 | example_code: "code", 211 | }) 212 | } 213 | 214 | /// Format code using rustfmt 215 | #[poise::command( 216 | prefix_command, 217 | track_edits, 218 | help_text_fn = "fmt_help", 219 | category = "Playground" 220 | )] 221 | pub async fn fmt( 222 | ctx: Context<'_>, 223 | flags: poise::KeyValueArgs, 224 | code: poise::CodeBlock, 225 | ) -> Result<(), Error> { 226 | ctx.say(stub_message(ctx)).await?; 227 | 228 | let code = &maybe_wrap(&code.code, ResultHandling::None); 229 | let was_fn_main_wrapped = matches!(code, Cow::Owned(_)); 230 | let (flags, flag_parse_errors) = parse_flags(flags); 231 | 232 | let mut result = apply_online_rustfmt(ctx, code, flags.edition).await?; 233 | 234 | if was_fn_main_wrapped { 235 | result.stdout = strip_fn_main_boilerplate_from_formatted(&result.stdout); 236 | } 237 | 238 | send_reply(ctx, result, code, &flags, &flag_parse_errors).await 239 | } 240 | 241 | #[must_use] 242 | pub fn fmt_help() -> String { 243 | generic_help(GenericHelp { 244 | command: "fmt", 245 | desc: "Format code using rustfmt", 246 | mode_and_channel: false, 247 | warn: false, 248 | aliasing_model: false, 249 | run: false, 250 | example_code: "code", 251 | }) 252 | } 253 | -------------------------------------------------------------------------------- /src/commands/playground/play_eval.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | 3 | use crate::types::Context; 4 | 5 | use super::{ 6 | api::{CrateType, PlayResult, PlaygroundRequest}, 7 | util::{ 8 | format_play_eval_stderr, generic_help, maybe_wrapped, parse_flags, send_reply, 9 | stub_message, GenericHelp, ResultHandling, 10 | }, 11 | }; 12 | 13 | // play and eval work similarly, so this function abstracts over the two 14 | async fn play_or_eval( 15 | ctx: Context<'_>, 16 | flags: poise::KeyValueArgs, 17 | force_warnings: bool, // If true, force enable warnings regardless of flags 18 | code: poise::CodeBlock, 19 | result_handling: ResultHandling, 20 | ) -> Result<(), Error> { 21 | ctx.say(stub_message(ctx)).await?; 22 | 23 | let code = maybe_wrapped( 24 | &code.code, 25 | result_handling, 26 | ctx.prefix().contains("Sweat"), 27 | ctx.prefix().contains("OwO") || ctx.prefix().contains("Cat"), 28 | ); 29 | let (mut flags, flag_parse_errors) = parse_flags(flags); 30 | 31 | if force_warnings { 32 | flags.warn = true; 33 | } 34 | 35 | let mut result: PlayResult = ctx 36 | .data() 37 | .http 38 | .post("https://play.rust-lang.org/execute") 39 | .json(&PlaygroundRequest { 40 | code: &code, 41 | channel: flags.channel, 42 | crate_type: CrateType::Binary, 43 | edition: flags.edition, 44 | mode: flags.mode, 45 | tests: false, 46 | }) 47 | .send() 48 | .await? 49 | .json() 50 | .await?; 51 | 52 | result.stderr = format_play_eval_stderr(&result.stderr, flags.warn); 53 | 54 | send_reply(ctx, result, &code, &flags, &flag_parse_errors).await 55 | } 56 | 57 | /// Compile and run Rust code in a playground 58 | #[poise::command( 59 | prefix_command, 60 | track_edits, 61 | help_text_fn = "play_help", 62 | category = "Playground" 63 | )] 64 | pub async fn play( 65 | ctx: Context<'_>, 66 | flags: poise::KeyValueArgs, 67 | code: poise::CodeBlock, 68 | ) -> Result<(), Error> { 69 | play_or_eval(ctx, flags, false, code, ResultHandling::None).await 70 | } 71 | 72 | #[must_use] 73 | pub fn play_help() -> String { 74 | generic_help(GenericHelp { 75 | command: "play", 76 | desc: "Compile and run Rust code", 77 | mode_and_channel: true, 78 | warn: true, 79 | run: false, 80 | aliasing_model: false, 81 | example_code: "code", 82 | }) 83 | } 84 | 85 | /// Compile and run Rust code with warnings 86 | #[poise::command(prefix_command, 87 | track_edits, 88 | hide_in_help, // don't clutter help menu with something that ?play can do too 89 | help_text_fn = "playwarn_help", 90 | category = "Playground" 91 | )] 92 | pub async fn playwarn( 93 | ctx: Context<'_>, 94 | flags: poise::KeyValueArgs, 95 | code: poise::CodeBlock, 96 | ) -> Result<(), Error> { 97 | play_or_eval(ctx, flags, true, code, ResultHandling::None).await 98 | } 99 | 100 | #[must_use] 101 | pub fn playwarn_help() -> String { 102 | generic_help(GenericHelp { 103 | command: "playwarn", 104 | desc: "Compile and run Rust code with warnings. Equivalent to `?play warn=true`", 105 | mode_and_channel: true, 106 | warn: false, 107 | run: false, 108 | aliasing_model: false, 109 | example_code: "code", 110 | }) 111 | } 112 | 113 | /// Evaluate a single Rust expression 114 | #[poise::command( 115 | prefix_command, 116 | track_edits, 117 | help_text_fn = "eval_help", 118 | category = "Playground" 119 | )] 120 | pub async fn eval( 121 | ctx: Context<'_>, 122 | flags: poise::KeyValueArgs, 123 | code: poise::CodeBlock, 124 | ) -> Result<(), Error> { 125 | play_or_eval(ctx, flags, false, code, ResultHandling::Print).await 126 | } 127 | 128 | #[must_use] 129 | pub fn eval_help() -> String { 130 | generic_help(GenericHelp { 131 | command: "eval", 132 | desc: "Compile and run Rust code", 133 | mode_and_channel: true, 134 | warn: true, 135 | run: false, 136 | aliasing_model: false, 137 | example_code: "code", 138 | }) 139 | } 140 | -------------------------------------------------------------------------------- /src/commands/playground/procmacro.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | 3 | use crate::types::Context; 4 | 5 | use super::{ 6 | api::{Channel, CrateType, Edition, Mode, PlayResult, PlaygroundRequest}, 7 | util::{ 8 | format_play_eval_stderr, generic_help, maybe_wrap, parse_flags, send_reply, stub_message, 9 | GenericHelp, ResultHandling, 10 | }, 11 | }; 12 | 13 | /// Compile and use a procedural macro 14 | #[poise::command( 15 | prefix_command, 16 | track_edits, 17 | help_text_fn = "procmacro_help", 18 | category = "Playground" 19 | )] 20 | pub async fn procmacro( 21 | ctx: Context<'_>, 22 | flags: poise::KeyValueArgs, 23 | macro_code: poise::CodeBlock, 24 | usage_code: poise::CodeBlock, 25 | ) -> Result<(), Error> { 26 | ctx.say(stub_message(ctx)).await?; 27 | 28 | let macro_code = macro_code.code; 29 | let usage_code = maybe_wrap(&usage_code.code, ResultHandling::None); 30 | 31 | let (flags, flag_parse_errors) = parse_flags(flags); 32 | 33 | let mut generated_code = format!( 34 | stringify!( 35 | const MACRO_CODE: &str = r#####"{}"#####; 36 | const USAGE_CODE: &str = r#####"{}"#####; 37 | ), 38 | macro_code, usage_code 39 | ); 40 | generated_code += r#" 41 | pub fn cmd_run(cmd: &str) { 42 | let status = std::process::Command::new("/bin/sh") 43 | .args(&["-c", cmd]) 44 | .status() 45 | .unwrap(); 46 | if !status.success() { 47 | std::process::exit(-1); 48 | } 49 | } 50 | 51 | pub fn cmd_stdout(cmd: &str) -> String { 52 | let output = std::process::Command::new("/bin/sh") 53 | .args(&["-c", cmd]) 54 | .output() 55 | .unwrap(); 56 | String::from_utf8(output.stdout).unwrap() 57 | } 58 | 59 | fn main() -> std::io::Result<()> { 60 | use std::io::Write as _; 61 | std::env::set_current_dir(cmd_stdout("mktemp -d").trim())?; 62 | cmd_run("cargo init -q --name procmacro --lib"); 63 | std::fs::write("src/lib.rs", MACRO_CODE)?; 64 | std::fs::write("src/main.rs", USAGE_CODE)?; 65 | std::fs::OpenOptions::new() 66 | .write(true) 67 | .append(true) 68 | .open("Cargo.toml")? 69 | .write_all(b"[lib]\nproc-macro = true")?; 70 | cmd_run("cargo"#; 71 | generated_code += if flags.run { " r" } else { " c" }; 72 | generated_code += r#" -q --bin procmacro"); 73 | Ok(()) 74 | }"#; 75 | 76 | let mut result: PlayResult = ctx 77 | .data() 78 | .http 79 | .post("https://play.rust-lang.org/execute") 80 | .json(&PlaygroundRequest { 81 | code: &generated_code, 82 | channel: Channel::Nightly, // so that inner proc macro gets nightly too 83 | // These flags only apply to the glue code 84 | crate_type: CrateType::Binary, 85 | edition: Edition::E2024, 86 | mode: Mode::Debug, 87 | tests: false, 88 | }) 89 | .send() 90 | .await? 91 | .json() 92 | .await?; 93 | 94 | // funky 95 | result.stderr = format_play_eval_stderr( 96 | &format_play_eval_stderr(&result.stderr, flags.warn), 97 | flags.warn, 98 | ); 99 | 100 | send_reply(ctx, result, &generated_code, &flags, &flag_parse_errors).await 101 | } 102 | 103 | #[must_use] 104 | pub fn procmacro_help() -> String { 105 | generic_help(GenericHelp { 106 | command: "procmacro", 107 | desc: "\ 108 | Compiles a procedural macro by providing two snippets: one for the \ 109 | proc-macro code, and one for the usage code which can refer to the proc-macro crate as \ 110 | `procmacro`. By default, the code is only compiled, _not run_! To run the final code too, pass 111 | `run=true`.", 112 | mode_and_channel: false, 113 | warn: true, 114 | run: true, 115 | aliasing_model: false, 116 | example_code: " 117 | #[proc_macro] 118 | pub fn foo(_: proc_macro::TokenStream) -> proc_macro::TokenStream { 119 | r#\"compile_error!(\"Fish is on fire\")\"#.parse().unwrap() 120 | } 121 | ``\u{200B}` ``\u{200B}` 122 | procmacro::foo!(); 123 | ", 124 | }) 125 | } 126 | -------------------------------------------------------------------------------- /src/commands/playground/util.rs: -------------------------------------------------------------------------------- 1 | use core::fmt::Write as _; 2 | use std::borrow::Cow; 3 | 4 | use poise::serenity_prelude as serenity; 5 | use serenity::ComponentInteraction; 6 | 7 | use crate::types::Context; 8 | use crate::Error; 9 | 10 | use super::api; 11 | 12 | // Small thing about multiline strings: while hacking on this file I was unsure how to handle 13 | // trailing newlines in multiline strings: 14 | // - should they have one ("hello\nworld\n") 15 | // - or not? ("hello\nworld") 16 | // After considering several use cases and intensely thinking about it, I arrived at the 17 | // most mathematically sound and natural way: always have a trailing newline, except for the empty 18 | // string. This means, that there'll always be exactly as many newlines as lines, which is 19 | // mathematically sensible. It also means you can also naturally concat multiple multiline 20 | // strings, and `is_empty` will still work. 21 | // So that's how (hopefully) all semantically-multiline strings in this code work 22 | 23 | /// Returns the parsed flags and a String of parse errors. The parse error string will have a 24 | /// trailing newline (except if empty) 25 | pub fn parse_flags(mut args: poise::KeyValueArgs) -> (api::CommandFlags, String) { 26 | let mut errors = String::new(); 27 | 28 | let mut flags = api::CommandFlags { 29 | channel: api::Channel::Nightly, 30 | mode: api::Mode::Debug, 31 | edition: api::Edition::E2024, 32 | warn: false, 33 | run: false, 34 | aliasing_model: api::AliasingModel::Stacked, 35 | }; 36 | 37 | macro_rules! pop_flag { 38 | ($flag_name:literal, $flag_field:expr) => { 39 | if let Some(flag) = args.0.remove($flag_name) { 40 | match flag.parse() { 41 | Ok(x) => $flag_field = x, 42 | Err(e) => { 43 | writeln!(errors, "{e}").expect("Writing to a String should never fail") 44 | } 45 | } 46 | } 47 | }; 48 | } 49 | 50 | pop_flag!("channel", flags.channel); 51 | pop_flag!("mode", flags.mode); 52 | pop_flag!("edition", flags.edition); 53 | pop_flag!("warn", flags.warn); 54 | pop_flag!("run", flags.run); 55 | pop_flag!("aliasingModel", flags.aliasing_model); 56 | 57 | for (remaining_flag, _) in args.0 { 58 | writeln!(errors, "unknown flag `{remaining_flag}`") 59 | .expect("Writing to a String should never fail"); 60 | } 61 | 62 | (flags, errors) 63 | } 64 | 65 | #[allow(clippy::struct_excessive_bools)] 66 | #[derive(Clone, Copy)] 67 | pub struct GenericHelp<'a> { 68 | pub command: &'a str, 69 | pub desc: &'a str, 70 | pub mode_and_channel: bool, 71 | pub warn: bool, 72 | pub run: bool, 73 | pub aliasing_model: bool, 74 | pub example_code: &'a str, 75 | } 76 | 77 | pub fn generic_help(spec: GenericHelp<'_>) -> String { 78 | let mut reply = format!( 79 | "{}. All code is executed on https://play.rust-lang.org.\n", 80 | spec.desc 81 | ); 82 | 83 | reply += "```rust\n?"; 84 | reply += spec.command; 85 | if spec.mode_and_channel { 86 | reply += " mode={} channel={}"; 87 | } 88 | reply += " edition={}"; 89 | if spec.aliasing_model { 90 | reply += " aliasingModel={}"; 91 | } 92 | if spec.warn { 93 | reply += " warn={}"; 94 | } 95 | if spec.run { 96 | reply += " run={}"; 97 | } 98 | reply += " ``\u{200B}`"; 99 | reply += spec.example_code; 100 | reply += "``\u{200B}`\n```\n"; 101 | 102 | reply += "Optional arguments:\n"; 103 | if spec.mode_and_channel { 104 | reply += "- mode: debug, release (default: debug)\n"; 105 | reply += "- channel: stable, beta, nightly (default: nightly)\n"; 106 | } 107 | if spec.aliasing_model { 108 | reply += "- aliasingModel: stacked, tree (default: stacked)\n"; 109 | } 110 | reply += "- edition: 2015, 2018, 2021, 2024 (default: 2024)\n"; 111 | if spec.warn { 112 | reply += "- warn: true, false (default: false)\n"; 113 | } 114 | if spec.run { 115 | reply += "- run: true, false (default: false)\n"; 116 | } 117 | 118 | reply 119 | } 120 | 121 | /// Strip the input according to a list of start tokens and end tokens. Everything after the start 122 | /// token up to the end token is stripped. Remaining trailing or loading empty lines are removed as 123 | /// well. 124 | /// 125 | /// If multiple potential tokens could be used as a stripping point, this function will make the 126 | /// stripped output as compact as possible and choose from the matching tokens accordingly. 127 | // Note to self: don't use "Finished dev" as a parameter to this, because that will break in release 128 | // compilation mode 129 | pub fn extract_relevant_lines<'a>( 130 | mut stderr: &'a str, 131 | strip_start_tokens: &[&str], 132 | strip_end_tokens: &[&str], 133 | ) -> &'a str { 134 | // Find best matching start token 135 | if let Some(start_token_pos) = strip_start_tokens 136 | .iter() 137 | .filter_map(|t| stderr.rfind(t)) 138 | .max() 139 | { 140 | // Keep only lines after that 141 | stderr = match stderr[start_token_pos..].find('\n') { 142 | Some(line_end) => &stderr[(line_end + start_token_pos + 1)..], 143 | None => "", 144 | }; 145 | } 146 | 147 | // Find best matching end token 148 | if let Some(end_token_pos) = strip_end_tokens 149 | .iter() 150 | .filter_map(|t| stderr.rfind(t)) 151 | .min() 152 | { 153 | // Keep only lines before that 154 | stderr = match stderr[..end_token_pos].rfind('\n') { 155 | Some(prev_line_end) => &stderr[..=prev_line_end], 156 | None => "", 157 | }; 158 | } 159 | 160 | // Strip trailing or leading empty lines 161 | stderr = stderr.trim_start_matches('\n'); 162 | while stderr.ends_with("\n\n") { 163 | stderr = &stderr[..(stderr.len() - 1)]; 164 | } 165 | 166 | stderr 167 | } 168 | 169 | #[derive(Clone, Copy)] 170 | pub enum ResultHandling { 171 | /// Don't consume results at all, making rustc throw an error when the result isn't () 172 | None, 173 | /// Consume using `let _ = { ... };` 174 | Discard, 175 | /// Print the result with `println!("{:?}")` 176 | Print, 177 | } 178 | 179 | pub fn hoise_crate_attributes(code: &str, after_crate_attrs: &str, after_code: &str) -> String { 180 | let mut lines = code.lines().peekable(); 181 | 182 | let mut output = String::new(); 183 | 184 | // First go through the input lines and extract the crate attributes at the start. Those will 185 | // be put right at the beginning of the generated code, else they won't work (crate attributes 186 | // need to be at the top of the file) 187 | while let Some(line) = lines.peek() { 188 | let line = line.trim(); 189 | if line.starts_with("#![") { 190 | output.push_str(line); 191 | output.push('\n'); 192 | } else if line.is_empty() { 193 | // do nothing, maybe more crate attributes are coming 194 | } else { 195 | break; 196 | } 197 | lines.next(); // Advance the iterator 198 | } 199 | 200 | output.push_str(after_crate_attrs); 201 | 202 | // Write the rest of the lines that don't contain crate attributes 203 | for line in lines { 204 | output.push_str(line); 205 | output.push('\n'); 206 | } 207 | 208 | output.push_str(after_code); 209 | 210 | output 211 | } 212 | 213 | /// Utility used by the commands to wrap the given code in a `fn main` if not already wrapped. 214 | /// To check, whether a wrap was done, check if the return type is `Cow::Borrowed` vs `Cow::Owned` 215 | /// If a wrap was done, also hoists crate attributes to the top so they keep working 216 | pub fn maybe_wrap(code: &str, result_handling: ResultHandling) -> Cow<'_, str> { 217 | maybe_wrapped(code, result_handling, false, false) 218 | } 219 | 220 | pub fn maybe_wrapped( 221 | code: &str, 222 | result_handling: ResultHandling, 223 | unsf: bool, 224 | pretty: bool, 225 | ) -> Cow<'_, str> { 226 | #[allow(clippy::wildcard_imports)] 227 | use syn::{parse::Parse, *}; 228 | 229 | // We use syn to check whether there is a main function. 230 | struct Inline {} 231 | 232 | impl Parse for Inline { 233 | fn parse(input: parse::ParseStream<'_>) -> Result { 234 | Attribute::parse_inner(input)?; 235 | let stmts = Block::parse_within(input)?; 236 | for stmt in &stmts { 237 | if let Stmt::Item(Item::Fn(ItemFn { sig, .. })) = stmt { 238 | if sig.ident == "main" && sig.inputs.is_empty() { 239 | return Err(input.error("main")); 240 | } 241 | } 242 | } 243 | Ok(Self {}) 244 | } 245 | } 246 | 247 | let Ok(Inline { .. }) = parse_str::(code) else { 248 | return Cow::Borrowed(code); 249 | }; 250 | 251 | // These string subsitutions are not quite optimal, but they perfectly preserve formatting, which is very important. 252 | // This function must not change the formatting of the supplied code or it will be confusing and hard to use. 253 | 254 | // fn main boilerplate 255 | let mut after_crate_attrs = match result_handling { 256 | ResultHandling::None => "fn main() {\n", 257 | ResultHandling::Discard => "fn main() { let _ = {\n", 258 | ResultHandling::Print if pretty => "fn main() { println!(\"{:#?}\", {\n", 259 | ResultHandling::Print => "fn main() { println!(\"{:?}\", {\n", 260 | } 261 | .to_owned(); 262 | 263 | if unsf { 264 | after_crate_attrs = format!("{after_crate_attrs}unsafe {{"); 265 | } 266 | 267 | // fn main boilerplate counterpart 268 | let mut after_code = match result_handling { 269 | ResultHandling::None => "}", 270 | ResultHandling::Discard => "}; }", 271 | ResultHandling::Print => "}); }", 272 | } 273 | .to_owned(); 274 | 275 | if unsf { 276 | after_code = format!("}}{after_code}"); 277 | } 278 | 279 | Cow::Owned(hoise_crate_attributes( 280 | code, 281 | &after_crate_attrs, 282 | &after_code, 283 | )) 284 | } 285 | 286 | /// Send a Discord reply with the formatted contents of a Playground result 287 | pub async fn send_reply( 288 | ctx: Context<'_>, 289 | result: api::PlayResult, 290 | code: &str, 291 | flags: &api::CommandFlags, 292 | flag_parse_errors: &str, 293 | ) -> Result<(), Error> { 294 | let result = crate::helpers::merge_output_and_errors(&result.stdout, &result.stderr); 295 | 296 | // Discord displays empty code blocks weirdly if they're not formatted in a specific style, 297 | // so we special-case empty code blocks 298 | if result.trim().is_empty() { 299 | ctx.say(format!("{flag_parse_errors}``` ```")).await?; 300 | return Ok(()); 301 | } 302 | 303 | let timeout = 304 | result.contains("Killed") && result.contains("timeout") && result.contains("--signal=KILL"); 305 | 306 | let mut text_end = String::from("```"); 307 | if timeout { 308 | text_end += "Playground timeout detected"; 309 | } 310 | 311 | let text = crate::helpers::trim_text( 312 | &format!("{flag_parse_errors}```rust\n{result}"), 313 | &text_end, 314 | async { 315 | format!( 316 | "Output too large. Playground link: <{}>", 317 | api::url_from_gist(flags, &api::post_gist(ctx, code).await.unwrap_or_default()), 318 | ) 319 | }, 320 | ) 321 | .await; 322 | 323 | let custom_id = ctx.id().to_string(); 324 | 325 | let response = ctx 326 | .send({ 327 | let mut b = poise::CreateReply::default().content(text); 328 | if timeout { 329 | b = b.components(vec![serenity::CreateActionRow::Buttons(vec![ 330 | serenity::CreateButton::new(&custom_id) 331 | .label("Retry") 332 | .style(serenity::ButtonStyle::Primary), 333 | ])]); 334 | } 335 | b 336 | }) 337 | .await?; 338 | 339 | if let Some(retry_pressed) = response 340 | .message() 341 | .await? 342 | .await_component_interaction(ctx) 343 | .filter(move |mci: &ComponentInteraction| mci.data.custom_id == custom_id) 344 | .timeout(std::time::Duration::from_secs(600)) 345 | .await 346 | { 347 | retry_pressed.defer(&ctx).await?; 348 | ctx.rerun().await?; 349 | } else { 350 | // If timed out, just remove the button 351 | // Errors are ignored in case the reply was deleted 352 | let _ = response 353 | // TODO: Add code to remove button 354 | .edit(ctx, poise::CreateReply::default()) 355 | .await; 356 | } 357 | 358 | Ok(()) 359 | } 360 | 361 | // This function must not break when provided non-formatted text with messed up formatting: rustfmt 362 | // may not be installed on the host's computer! 363 | pub fn strip_fn_main_boilerplate_from_formatted(text: &str) -> String { 364 | // Remove the fn main boilerplate 365 | let prefix = "fn main() {"; 366 | let postfix = "}"; 367 | 368 | let text = match (text.find(prefix), text.rfind(postfix)) { 369 | (Some(prefix_pos), Some(postfix_pos)) => text 370 | .get((prefix_pos + prefix.len())..postfix_pos) 371 | .unwrap_or(text), 372 | _ => text, 373 | }; 374 | let text = text.trim(); 375 | 376 | // Revert the indent introduced by rustfmt 377 | let mut output = String::new(); 378 | for line in text.lines() { 379 | output.push_str(line.strip_prefix(" ").unwrap_or(line)); 380 | output.push('\n'); 381 | } 382 | output 383 | } 384 | 385 | /// Split stderr into compiler output and program stderr output and format the two nicely 386 | /// 387 | /// If the program doesn't compile, the compiler output is returned. If it did compile and run, 388 | /// compiler output (i.e. warnings) is shown only when `show_compiler_warnings` is true. 389 | pub fn format_play_eval_stderr(stderr: &str, show_compiler_warnings: bool) -> String { 390 | // Extract core compiler output and remove boilerplate lines from top and bottom 391 | let compiler_output = extract_relevant_lines( 392 | stderr, 393 | &["Compiling playground"], 394 | &[ 395 | "warning emitted", 396 | "warnings emitted", 397 | "warning: `playground` (bin \"playground\") generated", 398 | "warning: `playground` (lib) generated", 399 | "error: could not compile", 400 | "error: aborting", 401 | "Finished ", 402 | ], 403 | ); 404 | 405 | // If the program actually ran, compose compiler output and program stderr 406 | // Using "Finished " here instead of "Running `target" because this method is also used by 407 | // e.g. -Zunpretty=XXX family commands which don't actually run anything 408 | if stderr.contains("Finished ") { 409 | // Program successfully compiled, so compiler output will be just warnings 410 | let program_stderr = extract_relevant_lines(stderr, &["Finished ", "Running `target"], &[]); 411 | 412 | if show_compiler_warnings { 413 | // Concatenate compiler output and program stderr with a newline 414 | match (compiler_output, program_stderr) { 415 | ("", "") => String::new(), 416 | (warnings, "") => warnings.to_owned(), 417 | ("", stderr) => stderr.to_owned(), 418 | (warnings, stderr) => format!("{warnings}\n{stderr}"), 419 | } 420 | } else { 421 | program_stderr.to_owned() 422 | } 423 | .replace('`', "\u{200b}`") 424 | } else { 425 | // Program didn't get to run, so there must be an error, so we yield the compiler output 426 | // regardless of whether warn is enabled or not 427 | compiler_output.to_owned() 428 | } 429 | } 430 | 431 | pub fn stub_message(ctx: Context<'_>) -> String { 432 | let mut stub_message = String::from("_Running code on playground..._\n"); 433 | 434 | if let Context::Prefix(ctx) = ctx { 435 | if let Some(edit_tracker) = &ctx.framework.options().prefix_options.edit_tracker { 436 | if let Some(existing_response) = 437 | edit_tracker.read().unwrap().find_bot_response(ctx.msg.id) 438 | { 439 | stub_message += &existing_response.content; 440 | } 441 | } 442 | } 443 | 444 | stub_message.truncate(2000); 445 | stub_message 446 | } 447 | -------------------------------------------------------------------------------- /src/commands/thread_pin.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use poise::serenity_prelude as serenity; 3 | 4 | use crate::types::Context; 5 | 6 | enum ThreadPinError { 7 | NoChannel, 8 | NotThread, 9 | ThreadLocked, 10 | NotThreadOwner, 11 | } 12 | 13 | async fn can_pin_in_thread(ctx: Context<'_>) -> Result<(), ThreadPinError> { 14 | if crate::checks::is_moderator(ctx) { 15 | return Ok(()); 16 | } 17 | 18 | let Some(channel) = ctx.guild_channel().await else { 19 | return Err(ThreadPinError::NoChannel); 20 | }; 21 | 22 | let Some(thread_metadata) = channel.thread_metadata else { 23 | return Err(ThreadPinError::NotThread); 24 | }; 25 | 26 | if thread_metadata.locked { 27 | return Err(ThreadPinError::ThreadLocked); 28 | } 29 | 30 | if channel.owner_id != Some(ctx.author().id) { 31 | return Err(ThreadPinError::NotThreadOwner); 32 | } 33 | 34 | Ok(()) 35 | } 36 | 37 | #[poise::command(context_menu_command = "Pin Message to Thread", guild_only)] 38 | pub async fn thread_pin(ctx: Context<'_>, message: serenity::Message) -> Result<()> { 39 | let reply = match can_pin_in_thread(ctx).await { 40 | Ok(()) => { 41 | message.pin(ctx.serenity_context()).await?; 42 | "Pinned message to your thread!" 43 | } 44 | Err(ThreadPinError::NoChannel) => "Error: Cannot fetch any information about this channel!", 45 | Err(ThreadPinError::NotThread) => "This channel is not a thread!", 46 | Err(ThreadPinError::NotThreadOwner) => { 47 | "You did not create this thread, so cannot pin messages to it." 48 | } 49 | Err(ThreadPinError::ThreadLocked) => { 50 | "This thread has been locked, so this cannot be performed." 51 | } 52 | }; 53 | 54 | let reply = poise::CreateReply::default().content(reply).ephemeral(true); 55 | ctx.send(reply).await?; 56 | Ok(()) 57 | } 58 | -------------------------------------------------------------------------------- /src/commands/utilities.rs: -------------------------------------------------------------------------------- 1 | use std::sync::LazyLock; 2 | 3 | use anyhow::{anyhow, Error}; 4 | use poise::serenity_prelude as serenity; 5 | use poise::serenity_prelude::Timestamp; 6 | use rand::Rng; 7 | 8 | use crate::types::Context; 9 | 10 | /// Evaluates Go code 11 | #[poise::command( 12 | prefix_command, 13 | slash_command, 14 | category = "Utilities", 15 | discard_spare_arguments 16 | )] 17 | pub async fn go(ctx: Context<'_>) -> Result<(), Error> { 18 | if rand::rng().random_bool(0.01) { 19 | ctx.say("Yes").await?; 20 | } else { 21 | ctx.say("No").await?; 22 | } 23 | Ok(()) 24 | } 25 | 26 | /// Links to the bot GitHub repo 27 | #[poise::command( 28 | prefix_command, 29 | slash_command, 30 | category = "Utilities", 31 | discard_spare_arguments 32 | )] 33 | pub async fn source(ctx: Context<'_>) -> Result<(), Error> { 34 | ctx.say("https://github.com/rust-community-discord/ferrisbot-for-discord") 35 | .await?; 36 | Ok(()) 37 | } 38 | 39 | /// Show this menu 40 | #[poise::command(prefix_command, slash_command, category = "Utilities", track_edits)] 41 | pub async fn help( 42 | ctx: Context<'_>, 43 | #[description = "Specific command to show help about"] 44 | #[autocomplete = "poise::builtins::autocomplete_command"] 45 | command: Option, 46 | ) -> Result<(), Error> { 47 | let extra_text_at_bottom = "\ 48 | You can still use all commands with `?`, even if it says `/` above. 49 | Type ?help command for more info on a command. 50 | You can edit your message to the bot and the bot will edit its response."; 51 | 52 | poise::builtins::help( 53 | ctx, 54 | command.as_deref(), 55 | poise::builtins::HelpConfiguration { 56 | extra_text_at_bottom, 57 | ephemeral: true, 58 | ..Default::default() 59 | }, 60 | ) 61 | .await?; 62 | Ok(()) 63 | } 64 | 65 | /// Register slash commands in this guild or globally 66 | #[poise::command( 67 | prefix_command, 68 | slash_command, 69 | category = "Utilities", 70 | hide_in_help, 71 | check = "crate::checks::check_is_moderator" 72 | )] 73 | pub async fn register(ctx: Context<'_>) -> Result<(), Error> { 74 | poise::builtins::register_application_commands_buttons(ctx).await?; 75 | 76 | Ok(()) 77 | } 78 | 79 | /// Tells you how long the bot has been up for 80 | #[poise::command(prefix_command, slash_command, category = "Utilities")] 81 | pub async fn uptime(ctx: Context<'_>) -> Result<(), Error> { 82 | let uptime = ctx.data().bot_start_time.elapsed(); 83 | 84 | let div_mod = |a, b| (a / b, a % b); 85 | 86 | let seconds = uptime.as_secs(); 87 | let (minutes, seconds) = div_mod(seconds, 60); 88 | let (hours, minutes) = div_mod(minutes, 60); 89 | let (days, hours) = div_mod(hours, 24); 90 | 91 | ctx.say(format!("Uptime: {days}d {hours}h {minutes}m {seconds}s")) 92 | .await?; 93 | 94 | Ok(()) 95 | } 96 | 97 | /// Use this joke command to have Conrad Ludgate tell you to get something 98 | /// 99 | /// Example: `/conradluget a better computer` 100 | #[poise::command( 101 | prefix_command, 102 | slash_command, 103 | category = "Utilities", 104 | track_edits, 105 | hide_in_help 106 | )] 107 | pub async fn conradluget( 108 | ctx: Context<'_>, 109 | #[description = "Get what?"] 110 | #[rest] 111 | text: String, 112 | ) -> Result<(), Error> { 113 | static BASE_IMAGE: LazyLock = LazyLock::new(|| { 114 | image::ImageReader::with_format( 115 | std::io::Cursor::new(&include_bytes!("../../assets/conrad.png")[..]), 116 | image::ImageFormat::Png, 117 | ) 118 | .decode() 119 | .expect("failed to load image") 120 | }); 121 | static FONT: LazyLock> = LazyLock::new(|| { 122 | ab_glyph::FontRef::try_from_slice(include_bytes!("../../assets/OpenSans.ttf")) 123 | .expect("failed to load font") 124 | }); 125 | 126 | let text = format!("Get {text}"); 127 | let image = imageproc::drawing::draw_text( 128 | &*BASE_IMAGE, 129 | image::Rgba([201, 209, 217, 255]), 130 | 57, 131 | 286, 132 | 65.0, 133 | &*FONT, 134 | &text, 135 | ); 136 | 137 | let mut img_bytes = Vec::with_capacity(200_000); // preallocate 200kB for the img 138 | image::DynamicImage::ImageRgba8(image).write_to( 139 | &mut std::io::Cursor::new(&mut img_bytes), 140 | image::ImageFormat::Png, 141 | )?; 142 | 143 | let filename = text + ".png"; 144 | 145 | let attachment = serenity::CreateAttachment::bytes(img_bytes, filename); 146 | 147 | ctx.send(poise::CreateReply::default().attachment(attachment)) 148 | .await?; 149 | 150 | Ok(()) 151 | } 152 | 153 | /// Deletes the bot's messages for cleanup 154 | /// 155 | /// /cleanup [limit] 156 | /// 157 | /// By default, only the most recent bot message is deleted (limit = 1). 158 | /// 159 | /// Deletes the bot's messages for cleanup. 160 | /// You can specify how many messages to look for. Only the 20 most recent messages within the 161 | /// channel from the last 24 hours can be deleted. 162 | #[poise::command( 163 | prefix_command, 164 | slash_command, 165 | category = "Utilities", 166 | on_error = "crate::helpers::acknowledge_fail" 167 | )] 168 | pub async fn cleanup( 169 | ctx: Context<'_>, 170 | #[description = "Number of messages to delete"] num_messages: Option, 171 | ) -> Result<(), Error> { 172 | let num_messages = num_messages.unwrap_or(1); 173 | 174 | let messages_to_delete = ctx 175 | .channel_id() 176 | .messages(&ctx, serenity::GetMessages::new().limit(20)) 177 | .await? 178 | .into_iter() 179 | .filter(|msg| { 180 | (msg.author.id == ctx.data().application_id) 181 | && (*ctx.created_at() - *msg.timestamp).num_hours() < 24 182 | }) 183 | .take(num_messages); 184 | 185 | ctx.channel_id() 186 | .delete_messages(&ctx, messages_to_delete) 187 | .await?; 188 | 189 | crate::helpers::acknowledge_success(ctx, "rustOk", '👌').await 190 | } 191 | 192 | /// Bans another person 193 | /// 194 | /// /ban [reason] 195 | /// 196 | /// Bans another person 197 | #[poise::command( 198 | prefix_command, 199 | slash_command, 200 | category = "Utilities", 201 | on_error = "crate::helpers::acknowledge_fail" 202 | )] 203 | pub async fn ban( 204 | ctx: Context<'_>, 205 | #[description = "Banned user"] banned_user: serenity::Member, 206 | #[description = "Ban reason"] 207 | #[rest] 208 | _reason: Option, 209 | ) -> Result<(), Error> { 210 | ctx.say(format!( 211 | "Banned user {} {}", 212 | banned_user.user.name, 213 | crate::helpers::custom_emoji_code(ctx, "ferrisBanne", '🔨') 214 | )) 215 | .await?; 216 | Ok(()) 217 | } 218 | 219 | /// Self-timeout yourself. 220 | /// 221 | /// /selftimeout [duration_in_hours] [duration_in_minutes] 222 | /// 223 | /// Self-timeout yourself. 224 | /// You can specify how long you want to timeout yourself for, either in hours 225 | /// or in minutes. 226 | #[poise::command( 227 | slash_command, 228 | category = "Utilities", 229 | on_error = "crate::helpers::acknowledge_fail" 230 | )] 231 | pub async fn selftimeout( 232 | ctx: Context<'_>, 233 | #[description = "Duration of self-timeout in hours"] duration_in_hours: Option, 234 | #[description = "Duration of self-timeout in minutes"] duration_in_minutes: Option, 235 | ) -> Result<(), Error> { 236 | let total_seconds = match (duration_in_hours, duration_in_minutes) { 237 | (None, None) => 3600, // When nothing is specified, default to one hour. 238 | (hours, minutes) => hours.unwrap_or(0) * 3600 + minutes.unwrap_or(0) * 60, 239 | }; 240 | 241 | let now = ctx.created_at().unix_timestamp(); 242 | 243 | let then = Timestamp::from_unix_timestamp(now + total_seconds as i64)?; 244 | 245 | let mut member = ctx 246 | .author_member() 247 | .await 248 | .ok_or(anyhow!("failed to fetch member"))? 249 | .into_owned(); 250 | 251 | member 252 | .disable_communication_until_datetime(&ctx, then) 253 | .await?; 254 | 255 | ctx.say(format!( 256 | "Self-timeout for {}. They'll be able to interact with the server again . \ 257 | If this was a mistake, please contact a moderator or try to enjoy the time off.", 258 | ctx.author().name, 259 | then.unix_timestamp() 260 | )) 261 | .await?; 262 | 263 | Ok(()) 264 | } 265 | -------------------------------------------------------------------------------- /src/helpers.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Error; 2 | use poise::serenity_prelude as serenity; 3 | use tracing::warn; 4 | 5 | use crate::types::{Context, Data}; 6 | 7 | /// Used for playground stdout + stderr, or godbolt asm + stderr 8 | /// If the return value is empty, returns " " instead, because Discord displays those better in 9 | /// a code block than "". 10 | #[must_use] 11 | pub fn merge_output_and_errors<'a>(output: &'a str, errors: &'a str) -> std::borrow::Cow<'a, str> { 12 | match (output.trim(), errors.trim()) { 13 | ("", "") => " ".into(), 14 | (output, "") => output.into(), 15 | ("", errors) => errors.into(), 16 | (output, errors) => format!("{errors}\n\n{output}").into(), 17 | } 18 | } 19 | 20 | /// In prefix commands, react with a red cross emoji. In slash commands, respond with a short 21 | /// explanation. 22 | pub async fn acknowledge_fail(error: poise::FrameworkError<'_, Data, Error>) { 23 | if let poise::FrameworkError::Command { error, ctx, .. } = error { 24 | warn!("Reacting with red cross because of error: {}", error); 25 | 26 | match ctx { 27 | Context::Application(_) => { 28 | if let Err(e) = ctx.say(format!("❌ {error}")).await { 29 | warn!( 30 | "Failed to send failure acknowledgment slash command response: {}", 31 | e 32 | ); 33 | } 34 | } 35 | Context::Prefix(prefix_context) => { 36 | if let Err(e) = prefix_context 37 | .msg 38 | .react(ctx, serenity::ReactionType::from('❌')) 39 | .await 40 | { 41 | warn!("Failed to react with red cross: {}", e); 42 | } 43 | } 44 | } 45 | } else { 46 | // crate::on_error(error).await; 47 | } 48 | } 49 | 50 | #[must_use] 51 | pub fn find_custom_emoji(ctx: Context<'_>, emoji_name: &str) -> Option { 52 | ctx.guild_id()? 53 | .to_guild_cached(&ctx)? 54 | .emojis 55 | .values() 56 | .find(|emoji| emoji.name.eq_ignore_ascii_case(emoji_name)) 57 | .cloned() 58 | } 59 | 60 | #[must_use] 61 | pub fn custom_emoji_code(ctx: Context<'_>, emoji_name: &str, fallback: char) -> String { 62 | match find_custom_emoji(ctx, emoji_name) { 63 | Some(emoji) => emoji.to_string(), 64 | None => fallback.to_string(), 65 | } 66 | } 67 | 68 | /// In prefix commands, react with a custom emoji from the guild, or fallback to a default Unicode 69 | /// emoji. 70 | /// 71 | /// In slash commands, currently nothing happens. 72 | pub async fn acknowledge_success( 73 | ctx: Context<'_>, 74 | emoji_name: &str, 75 | fallback: char, 76 | ) -> Result<(), Error> { 77 | let emoji = find_custom_emoji(ctx, emoji_name); 78 | match ctx { 79 | Context::Prefix(prefix_context) => { 80 | let reaction = emoji.map_or_else( 81 | || serenity::ReactionType::from(fallback), 82 | serenity::ReactionType::from, 83 | ); 84 | 85 | prefix_context.msg.react(&ctx, reaction).await?; 86 | } 87 | Context::Application(_) => { 88 | let msg_content = match emoji { 89 | Some(e) => e.to_string(), 90 | None => fallback.to_string(), 91 | }; 92 | if let Ok(reply) = ctx.say(msg_content).await { 93 | tokio::time::sleep(std::time::Duration::from_secs(3)).await; 94 | let msg = reply.message().await?; 95 | // ignore errors as to not fail if ephemeral 96 | let _: Result<_, _> = msg.delete(&ctx).await; 97 | } 98 | } 99 | } 100 | Ok(()) 101 | } 102 | 103 | /// Truncates the message with a given truncation message if the 104 | /// text is too long. "Too long" means, it either goes beyond Discord's 2000 char message limit, 105 | /// or if the `text_body` has too many lines. 106 | /// 107 | /// Only `text_body` is truncated. `text_end` will always be appended at the end. This is useful 108 | /// for example for large code blocks. You will want to truncate the code block contents, but the 109 | /// finalizing triple backticks (` ` `) should always stay - that's what `text_end` is for. 110 | #[expect(clippy::doc_markdown)] // backticks cause clippy to freak out 111 | pub async fn trim_text( 112 | text_body: &str, 113 | text_end: &str, 114 | truncation_msg_future: impl std::future::Future, 115 | ) -> String { 116 | const MAX_OUTPUT_LINES: usize = 45; 117 | const MAX_OUTPUT_LENGTH: usize = 2000; 118 | 119 | let needs_truncating = text_body.len() + text_end.len() > MAX_OUTPUT_LENGTH 120 | || text_body.lines().count() > MAX_OUTPUT_LINES; 121 | 122 | if needs_truncating { 123 | let truncation_msg = truncation_msg_future.await; 124 | 125 | // truncate for length 126 | let text_body: String = text_body 127 | .chars() 128 | .take(MAX_OUTPUT_LENGTH - truncation_msg.len() - text_end.len()) 129 | .collect(); 130 | 131 | // truncate for lines 132 | let text_body = text_body 133 | .lines() 134 | .take(MAX_OUTPUT_LINES) 135 | .collect::>() 136 | .join("\n"); 137 | 138 | format!("{text_body}{text_end}{truncation_msg}") 139 | } else { 140 | format!("{text_body}{text_end}") 141 | } 142 | } 143 | 144 | pub async fn reply_potentially_long_text( 145 | ctx: Context<'_>, 146 | text_body: &str, 147 | text_end: &str, 148 | truncation_msg_future: impl std::future::Future, 149 | ) -> Result<(), Error> { 150 | ctx.say(trim_text(text_body, text_end, truncation_msg_future).await) 151 | .await?; 152 | Ok(()) 153 | } 154 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(rust_2018_idioms, clippy::pedantic)] 2 | #![allow( 3 | clippy::too_many_lines, 4 | clippy::missing_errors_doc, 5 | clippy::missing_panics_doc, 6 | clippy::cast_possible_wrap, 7 | clippy::module_name_repetitions, 8 | clippy::assigning_clones, // Too many false triggers 9 | )] 10 | 11 | use std::path::PathBuf; 12 | use std::sync::Arc; 13 | use std::time::Duration; 14 | 15 | use anyhow::{anyhow, Error}; 16 | use poise::serenity_prelude as serenity; 17 | use rand::{seq::IteratorRandom, Rng}; 18 | use shuttle_runtime::SecretStore; 19 | use shuttle_serenity::ShuttleSerenity; 20 | use tracing::{debug, info, warn}; 21 | 22 | use crate::commands::modmail::{create_modmail_thread, load_or_create_modmail_message}; 23 | use crate::types::Data; 24 | 25 | pub mod checks; 26 | pub mod commands; 27 | pub mod helpers; 28 | pub mod types; 29 | 30 | #[shuttle_runtime::main] 31 | async fn serenity( 32 | #[shuttle_runtime::Secrets] secret_store: SecretStore, 33 | #[shuttle_shared_db::Postgres] pool: sqlx::PgPool, 34 | ) -> ShuttleSerenity { 35 | const FAILED_CODEBLOCK: &str = "\ 36 | Missing code block. Please use the following markdown: 37 | `` `code here` `` 38 | or 39 | ```ansi 40 | `\x1b[0m`\x1b[0m`rust 41 | code here 42 | `\x1b[0m`\x1b[0m` 43 | ```"; 44 | 45 | let token = secret_store 46 | .get("DISCORD_TOKEN") 47 | .expect("Couldn't find your DISCORD_TOKEN!"); 48 | 49 | sqlx::migrate!() 50 | .run(&pool) 51 | .await 52 | .expect("Failed to run migrations"); 53 | 54 | let framework = poise::Framework::builder() 55 | .setup(move |ctx, ready, framework| { 56 | Box::pin(async move { 57 | let data = Data::new(&secret_store, pool)?; 58 | 59 | debug!("Registering commands..."); 60 | poise::builtins::register_in_guild( 61 | ctx, 62 | &framework.options().commands, 63 | data.discord_guild_id, 64 | ) 65 | .await?; 66 | 67 | debug!("Setting activity text"); 68 | ctx.set_activity(Some(serenity::ActivityData::listening("/help"))); 69 | 70 | load_or_create_modmail_message(ctx, &data).await?; 71 | 72 | info!("rustbot logged in as {}", ready.user.name); 73 | Ok(data) 74 | }) 75 | }) 76 | .options(poise::FrameworkOptions { 77 | commands: vec![ 78 | commands::man::man(), 79 | commands::crates::crate_(), 80 | commands::crates::doc(), 81 | commands::godbolt::godbolt(), 82 | commands::godbolt::mca(), 83 | commands::godbolt::llvmir(), 84 | commands::godbolt::targets(), 85 | commands::utilities::go(), 86 | commands::utilities::source(), 87 | commands::utilities::help(), 88 | commands::utilities::register(), 89 | commands::utilities::uptime(), 90 | commands::utilities::conradluget(), 91 | commands::utilities::cleanup(), 92 | commands::utilities::ban(), 93 | commands::utilities::selftimeout(), 94 | commands::thread_pin::thread_pin(), 95 | commands::modmail::modmail(), 96 | commands::modmail::modmail_context_menu_for_message(), 97 | commands::modmail::modmail_context_menu_for_user(), 98 | commands::playground::play(), 99 | commands::playground::playwarn(), 100 | commands::playground::eval(), 101 | commands::playground::miri(), 102 | commands::playground::expand(), 103 | commands::playground::clippy(), 104 | commands::playground::fmt(), 105 | commands::playground::microbench(), 106 | commands::playground::procmacro(), 107 | ], 108 | prefix_options: poise::PrefixFrameworkOptions { 109 | prefix: Some("?".into()), 110 | additional_prefixes: vec![ 111 | poise::Prefix::Literal("🦀 "), 112 | poise::Prefix::Literal("🦀"), 113 | poise::Prefix::Literal("<:ferris:358652670585733120> "), 114 | poise::Prefix::Literal("<:ferris:358652670585733120>"), 115 | poise::Prefix::Literal("<:ferrisballSweat:678714352450142239> "), 116 | poise::Prefix::Literal("<:ferrisballSweat:678714352450142239>"), 117 | poise::Prefix::Literal("<:ferrisCat:1183779700485664820> "), 118 | poise::Prefix::Literal("<:ferrisCat:1183779700485664820>"), 119 | poise::Prefix::Literal("<:ferrisOwO:579331467000283136> "), 120 | poise::Prefix::Literal("<:ferrisOwO:579331467000283136>"), 121 | poise::Prefix::Regex( 122 | "(yo |hey )?(crab|ferris|fewwis),? can you (please |pwease )?" 123 | .parse() 124 | .unwrap(), 125 | ), 126 | ], 127 | edit_tracker: Some(Arc::new(poise::EditTracker::for_timespan( 128 | Duration::from_secs(60 * 5), // 5 minutes 129 | ))), 130 | ..Default::default() 131 | }, 132 | // The global error handler for all error cases that may occur 133 | on_error: |error| { 134 | Box::pin(async move { 135 | warn!("Encountered error: {:?}", error); 136 | if let poise::FrameworkError::ArgumentParse { error, ctx, .. } = error { 137 | let response = if error.is::() { 138 | FAILED_CODEBLOCK.to_owned() 139 | } else if let Some(multiline_help) = &ctx.command().help_text { 140 | format!("**{error}**\n{multiline_help}") 141 | } else { 142 | error.to_string() 143 | }; 144 | 145 | if let Err(e) = ctx.say(response).await { 146 | warn!("{}", e); 147 | } 148 | } else if let poise::FrameworkError::Command { ctx, error, .. } = error { 149 | if error.is::() { 150 | if let Err(e) = ctx.say(FAILED_CODEBLOCK.to_owned()).await { 151 | warn!("{}", e); 152 | } 153 | } 154 | if let Err(e) = ctx.say(error.to_string()).await { 155 | warn!("{}", e); 156 | } 157 | } 158 | }) 159 | }, 160 | // This code is run before every command 161 | pre_command: |ctx| { 162 | Box::pin(async move { 163 | let channel_name = &ctx 164 | .channel_id() 165 | .name(&ctx) 166 | .await 167 | .unwrap_or_else(|_| "".to_owned()); 168 | let author = &ctx.author().name; 169 | 170 | info!( 171 | "{} in {} used slash command '{}'", 172 | author, 173 | channel_name, 174 | &ctx.invoked_command_name() 175 | ); 176 | }) 177 | }, 178 | // This code is run after a command if it was successful (returned Ok) 179 | post_command: |ctx| { 180 | Box::pin(async move { 181 | info!("Executed command {}!", ctx.command().qualified_name); 182 | }) 183 | }, 184 | // Every command invocation must pass this check to continue execution 185 | command_check: Some(|_ctx| Box::pin(async move { Ok(true) })), 186 | // Enforce command checks even for owners (enforced by default) 187 | // Set to true to bypass checks, which is useful for testing 188 | skip_checks_for_owners: false, 189 | event_handler: |ctx, event, _framework, data| { 190 | Box::pin(async move { event_handler(ctx, event, data).await }) 191 | }, 192 | // Disallow all mentions (except those to the replied user) by default 193 | allowed_mentions: Some(serenity::CreateAllowedMentions::new().replied_user(true)), 194 | ..Default::default() 195 | }) 196 | .build(); 197 | 198 | let intents = serenity::GatewayIntents::all(); 199 | 200 | let client = serenity::ClientBuilder::new(token, intents) 201 | .framework(framework) 202 | .await 203 | .map_err(|e| anyhow!(e))?; 204 | 205 | Ok(client.into()) 206 | } 207 | 208 | async fn event_handler( 209 | ctx: &serenity::Context, 210 | event: &serenity::FullEvent, 211 | data: &Data, 212 | ) -> Result<(), Error> { 213 | debug!( 214 | "Got an event in event handler: {:?}", 215 | event.snake_case_name() 216 | ); 217 | 218 | if let serenity::FullEvent::GuildMemberAddition { new_member } = event { 219 | const RUSTIFICATION_DELAY: u64 = 30; // in minutes 220 | 221 | tokio::time::sleep(std::time::Duration::from_secs(RUSTIFICATION_DELAY * 60)).await; 222 | 223 | // Ignore errors because the user may have left already 224 | let _: Result<_, _> = ctx 225 | .http 226 | .add_member_role( 227 | new_member.guild_id, 228 | new_member.user.id, 229 | data.rustacean_role_id, 230 | Some(&format!( 231 | "Automatically rustified after {RUSTIFICATION_DELAY} minutes" 232 | )), 233 | ) 234 | .await; 235 | } 236 | 237 | if let serenity::FullEvent::Ready { .. } = event { 238 | let http = ctx.http.clone(); 239 | tokio::spawn(init_server_icon_changer(http, data.discord_guild_id)); 240 | } 241 | 242 | if let serenity::FullEvent::InteractionCreate { 243 | interaction: serenity::Interaction::Component(component), 244 | .. 245 | } = event 246 | { 247 | if component.data.custom_id == "rplcs_create_new_modmail" { 248 | let message = "Created from modmail button"; 249 | create_modmail_thread(ctx, message, data, component.user.id).await?; 250 | } 251 | } 252 | 253 | Ok(()) 254 | } 255 | 256 | async fn fetch_icon_paths() -> tokio::io::Result> { 257 | let mut icon_paths = Vec::new(); 258 | let mut icon_path_iter = tokio::fs::read_dir("./assets/server-icons").await?; 259 | loop { 260 | let Ok(entry_opt) = icon_path_iter.next_entry().await else { 261 | continue; 262 | }; 263 | 264 | let Some(entry) = entry_opt else { 265 | break; 266 | }; 267 | 268 | let path = entry.path(); 269 | if path.is_file() { 270 | icon_paths.push(path); 271 | } 272 | } 273 | 274 | Ok(icon_paths.into()) 275 | } 276 | 277 | async fn init_server_icon_changer( 278 | ctx: impl serenity::CacheHttp, 279 | guild_id: serenity::GuildId, 280 | ) -> anyhow::Result<()> { 281 | let icon_paths = fetch_icon_paths() 282 | .await 283 | .map_err(|e| anyhow!("Failed to read server-icons directory: {e}"))?; 284 | 285 | loop { 286 | // Attempt to find all images and select one at random 287 | let icon = icon_paths.iter().choose(&mut rand::rng()); 288 | if let Some(icon_path) = icon { 289 | info!("Changing server icon to {:?}", icon_path); 290 | 291 | // Attempt to change the server icon 292 | let icon_change_result = async { 293 | let icon = serenity::CreateAttachment::path(icon_path).await?; 294 | let edit_guild = serenity::EditGuild::new().icon(Some(&icon)); 295 | guild_id.edit(&ctx, edit_guild).await 296 | } 297 | .await; 298 | 299 | if let Err(e) = icon_change_result { 300 | warn!("Failed to change server icon: {}", e); 301 | } 302 | } 303 | 304 | // Sleep for between 24 and 48 hours 305 | let sleep_duration = rand::rng().random_range((60 * 60 * 24)..(60 * 60 * 48)); 306 | tokio::time::sleep(Duration::from_secs(sleep_duration)).await; 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/types.rs: -------------------------------------------------------------------------------- 1 | use std::sync::Arc; 2 | 3 | use anyhow::{anyhow, Error, Result}; 4 | use poise::serenity_prelude as serenity; 5 | use shuttle_runtime::SecretStore; 6 | 7 | use crate::commands; 8 | 9 | #[derive(Debug)] 10 | pub struct Data { 11 | pub database: sqlx::PgPool, 12 | pub discord_guild_id: serenity::GuildId, 13 | pub application_id: serenity::UserId, 14 | pub mod_role_id: serenity::RoleId, 15 | pub rustacean_role_id: serenity::RoleId, 16 | pub modmail_channel_id: serenity::ChannelId, 17 | pub modmail_message: Arc>>, 18 | pub bot_start_time: std::time::Instant, 19 | pub http: reqwest::Client, 20 | pub godbolt_metadata: std::sync::Mutex, 21 | } 22 | 23 | impl Data { 24 | pub fn new(secret_store: &SecretStore, database: sqlx::PgPool) -> Result { 25 | Ok(Self { 26 | database, 27 | discord_guild_id: secret_store 28 | .get("DISCORD_GUILD") 29 | .ok_or(anyhow!( 30 | "Failed to get 'DISCORD_GUILD' from the secret store" 31 | ))? 32 | .parse::()? 33 | .into(), 34 | application_id: secret_store 35 | .get("APPLICATION_ID") 36 | .ok_or(anyhow!( 37 | "Failed to get 'APPLICATION_ID' from the secret store" 38 | ))? 39 | .parse::()? 40 | .into(), 41 | mod_role_id: secret_store 42 | .get("MOD_ROLE_ID") 43 | .ok_or(anyhow!("Failed to get 'MOD_ROLE_ID' from the secret store"))? 44 | .parse::()? 45 | .into(), 46 | rustacean_role_id: secret_store 47 | .get("RUSTACEAN_ROLE_ID") 48 | .ok_or(anyhow!( 49 | "Failed to get 'RUSTACEAN_ROLE_ID' from the secret store" 50 | ))? 51 | .parse::()? 52 | .into(), 53 | modmail_channel_id: secret_store 54 | .get("MODMAIL_CHANNEL_ID") 55 | .ok_or(anyhow!( 56 | "Failed to get 'MODMAIL_CHANNEL_ID' from the secret store" 57 | ))? 58 | .parse::()? 59 | .into(), 60 | modmail_message: Arc::default(), 61 | bot_start_time: std::time::Instant::now(), 62 | http: reqwest::Client::new(), 63 | godbolt_metadata: std::sync::Mutex::new(commands::godbolt::GodboltMetadata::default()), 64 | }) 65 | } 66 | } 67 | 68 | pub type Context<'a> = poise::Context<'a, Data, Error>; 69 | 70 | // const EMBED_COLOR: (u8, u8, u8) = (0xf7, 0x4c, 0x00); 71 | pub const EMBED_COLOR: (u8, u8, u8) = (0xb7, 0x47, 0x00); // slightly less saturated 72 | --------------------------------------------------------------------------------