├── .devcontainer └── devcontainer.json ├── .github └── workflows │ ├── check_pr.yml │ └── publish_container.yml ├── .gitignore ├── .gitpod.Dockerfile ├── .gitpod.yml ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── ExampleBotConfig.toml ├── LICENSE ├── README.md ├── rust-toolchain.toml ├── scripts └── deploy.sh └── src ├── command ├── about.rs ├── av.rs ├── bash.rs ├── exec.rs ├── index_threads.rs ├── invite.rs ├── latency.rs ├── mod.rs └── status.rs ├── config.rs ├── event ├── guild_create.rs ├── guild_member_addition.rs ├── guild_member_removal.rs ├── interaction_create │ ├── close_thread.rs │ ├── getting_started.rs │ ├── mod.rs │ ├── question_thread_suggestions.rs │ ├── questions.rs │ ├── slash_commands │ │ ├── create_pr.rs │ │ ├── mod.rs │ │ ├── nothing_to_see_here.rs │ │ └── pull.rs │ └── utils.rs ├── message.rs ├── message_delete.rs ├── message_update.rs ├── mod.rs ├── new_question.rs ├── reaction_add.rs ├── ready.rs └── thread_update.rs ├── init.rs ├── main.rs └── utils ├── index_threads.rs ├── misc.rs ├── mod.rs ├── parser.rs └── substr.rs /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "mcr.microsoft.com/devcontainers/universal:2", 3 | "features": { 4 | "ghcr.io/devcontainers/features/rust:1": {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/check_pr.yml: -------------------------------------------------------------------------------- 1 | name: Check if optimus is buildable 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | 7 | jobs: 8 | check-build: 9 | runs-on: ubuntu-22.04 10 | permissions: 11 | contents: read 12 | packages: write 13 | 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v3 17 | with: 18 | submodules: recursive 19 | 20 | - name: Check optimus 21 | run: docker run -v $(pwd):/volume --rm -t clux/muslrust:nightly cargo check 22 | -------------------------------------------------------------------------------- /.github/workflows/publish_container.yml: -------------------------------------------------------------------------------- 1 | name: Build optimus and publish a Docker image 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | 7 | env: 8 | REGISTRY: ghcr.io 9 | IMAGE_NAME: ${{ github.repository }} 10 | 11 | jobs: 12 | build-and-push-image: 13 | runs-on: ubuntu-22.04 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v3 21 | with: 22 | submodules: recursive 23 | 24 | - name: Build optimus 25 | run: docker run -v $(pwd):/volume --rm -t clux/muslrust:nightly cargo build --release 26 | 27 | - name: Log in to the Container registry 28 | uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9 29 | with: 30 | registry: ${{ env.REGISTRY }} 31 | username: ${{ github.actor }} 32 | password: ${{ secrets.GITHUB_TOKEN }} 33 | 34 | - name: Extract metadata (tags, labels) for Docker 35 | id: meta 36 | uses: docker/metadata-action@98669ae865ea3cffbcbaa878cf57c20bbf1c6c38 37 | with: 38 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 39 | 40 | - name: Build and push Docker image 41 | uses: docker/build-push-action@ad44023a93711e3deb337508980b4b5e9bcdc5dc 42 | with: 43 | context: . 44 | push: true 45 | tags: ${{ steps.meta.outputs.tags }} 46 | labels: ${{ steps.meta.outputs.labels }} 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /bot.sqlite* 3 | /data.ms 4 | /*BotConfig.toml 5 | /*.png 6 | -------------------------------------------------------------------------------- /.gitpod.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gitpod/workspace-base:2023-10-13-07-50-14 2 | 3 | USER root 4 | 5 | # Install meilisearch 6 | RUN target=/usr/bin/meilisearch \ 7 | && curl -o "${target}" \ 8 | -L https://github.com/meilisearch/meilisearch/releases/download/v1.1.0/meilisearch-linux-amd64 \ 9 | && chmod +x "${target}" 10 | 11 | USER gitpod 12 | 13 | ENV RUST_VERSION="nightly-2023-10-12" 14 | ENV PATH=$HOME/.cargo/bin:$PATH 15 | 16 | RUN curl -fsSL https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain ${RUST_VERSION} \ 17 | -c rls rust-analysis rust-src rustfmt clippy \ 18 | && for cmp in rustup cargo; do rustup completions bash "$cmp" > "$HOME/.local/share/bash-completion/completions/$cmp"; done \ 19 | && printf '%s\n' 'export CARGO_HOME=/workspace/.cargo' \ 20 | 'mkdir -m 0755 -p "$CARGO_HOME/bin" 2>/dev/null' \ 21 | 'export PATH=$CARGO_HOME/bin:$PATH' \ 22 | 'test ! -e "$CARGO_HOME/bin/rustup" && mv "$(command -v rustup)" "$CARGO_HOME/bin"' > $HOME/.bashrc.d/80-rust \ 23 | && rm -rf "$HOME/.cargo/registry" # This registry cache is now useless as we change the CARGO_HOME path to `/workspace` 24 | 25 | RUN rustup default ${RUST_VERSION} # not needed but anyway \ 26 | && rustup component add clippy \ 27 | && rustup component add rustfmt 28 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | image: 2 | file: .gitpod.Dockerfile 3 | 4 | tasks: 5 | - name: Project build (cache) and run instructions 6 | init: | 7 | cargo build 8 | 9 | # workaround for https://github.com/gitpod-io/gitpod/issues/524 10 | find target -exec stat --format='%.Y %n' {} + > /workspace/.ts 11 | command: | 12 | # workaround for https://github.com/gitpod-io/gitpod/issues/524 13 | while read -r ts file; do touch -d "@${ts}" "${file}"; done < /workspace/.ts 14 | 15 | - name: Setup BotConfig and run program 16 | before: | 17 | # Fully cleanup terminal 18 | printf "\033[3J\033c\033[3J" 19 | 20 | # Restore ProdBotConfig.toml 21 | if test -n "${PROD_DISCORD_BOT_CONFIG_ENCODED:-}"; then { 22 | base64 -d <<<"${PROD_DISCORD_BOT_CONFIG_ENCODED}" > ProdBotConfig.toml 23 | } fi 24 | 25 | # Restore config if exists and run bot 26 | config_name="BotConfig.toml" 27 | 28 | if test -n "${DISCORD_BOT_CONFIG_ENCODED:-}"; then { 29 | base64 -d <<< "${DISCORD_BOT_CONFIG_ENCODED}" > "${config_name}" 30 | } else { 31 | # Create "BotConfig.toml" 32 | cp ExampleBotConfig.toml "${config_name}" 33 | 34 | RC=$'\033[0m' 35 | BGREEN=$'\033[1;32m' 36 | YELLOW=$'\033[1;33m' 37 | BRED=$'\033[1;31m' 38 | 39 | printf '\n'; 40 | printf '%s\n' \ 41 | ">>> Created ${BGREEN}./${config_name}${RC} by copying ./ExampleBotConfig.toml" \ 42 | ">>> Please update/fill-up ${BGREEN}./${config_name}${RC} with the necessary information and run:" \ 43 | "# To persist the config change" \ 44 | " ${BRED}gp env DISCORD_BOT_CONFIG_ENCODED=\"\$(base64 -w0 ${config_name})\"${RC}" \ 45 | "# To execute the bot from cargo in debug variant" \ 46 | " ${YELLOW}cargo run -- ${config_name}${RC}" 47 | } fi 48 | command: | 49 | config_name="BotConfig.toml" 50 | 51 | if test -e "${config_name}"; then { 52 | cargo run -- "${config_name}" 53 | } fi 54 | 55 | vscode: 56 | extensions: 57 | - https://github.com/rust-lang/rust-analyzer/releases/download/2023-10-09/rust-analyzer-linux-x64.vsix 58 | - tamasfe.even-better-toml 59 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.check.command": "clippy", 3 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "optimus" 3 | version = "0.1.0" 4 | authors = ["AXON "] 5 | edition = "2021" 6 | 7 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 8 | 9 | [dependencies] 10 | serde_json = "1.0.107" 11 | serde = { version = "1.0.189", features = ["derive"] } 12 | regex = "1.10.0" 13 | urlencoding = "2.1.3" 14 | # sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "sqlite", "offline"] } 15 | meilisearch-sdk = "0.23.2" 16 | words-count = "0.1.6" 17 | html-escape = "0.2.13" 18 | piston_rs = "0.4.3" 19 | async-trait = "0.1.73" 20 | duplicate = "1.0.0" 21 | base64 = "0.21.4" 22 | once_cell = "1.18.0" 23 | color-eyre = "0.6.2" 24 | tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } 25 | tracing = "0.1.37" 26 | tracing-error = "0.2.0" 27 | toml = "0.8.2" 28 | url = "2.4.1" 29 | fastrand = "2.0.1" 30 | openai = { git = "https://github.com/rellfy/openai", rev = "280cf412581d6c5b8e239ce19ae647b877e01838" } 31 | sysinfo = "0.29.10" 32 | 33 | [dependencies.reqwest] 34 | default-features = false 35 | features = ["rustls-tls", "json"] 36 | version = "0.11.22" 37 | 38 | [dependencies.serenity] 39 | git = "https://github.com/serenity-rs/serenity" 40 | rev = "f42ec02" 41 | default-features = false 42 | features = ["client", "unstable_discord_api", "gateway", "rustls_backend", "model", "utils", "cache", "framework", "standard_framework", "collector"] 43 | #version = "0.10.10" 44 | 45 | [dependencies.tokio] 46 | version = "1.33.0" 47 | features = ["macros", "rt-multi-thread", "process"] 48 | 49 | 50 | [dependencies.anyhow] 51 | version = "1.0.75" 52 | features = ["backtrace"] 53 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM getmeili/meilisearch:v1.1 as meilisearch_alpine 2 | 3 | FROM alpine:3.16 4 | 5 | ARG APP_DIR="/app" 6 | ENV PATH="${APP_DIR}:${PATH}" 7 | RUN mkdir -m 0755 -p "${APP_DIR}" 8 | 9 | # Meilisearch 10 | # ENV MEILI_DB_PATH="/app/data.ms" 11 | # RUN url="https://github.com/meilisearch/meilisearch/releases/download/v1.1.0/meilisearch-linux-amd64" && \ 12 | # path="/app/meilisearch" \ 13 | # && curl -L "${url}" -o "${path}" \ 14 | # && chmod +x "${path}" 15 | RUN apk add --no-cache libgcc 16 | COPY --from=meilisearch_alpine --chown=root:root /bin/meilisearch ${APP_DIR}/meilisearch 17 | 18 | # Bot 19 | COPY target/x86_64-unknown-linux-musl/release/optimus ${APP_DIR}/optimus 20 | 21 | # Automatically start meilisearch, and read a BotConfig.toml from ${APP_DIR} (if exists) 22 | ENTRYPOINT ["optimus"] 23 | -------------------------------------------------------------------------------- /ExampleBotConfig.toml: -------------------------------------------------------------------------------- 1 | # Example BotConfig. 2 | ## Please fill it up with your own data. 3 | ## Do not commit in git. 4 | 5 | [discord] 6 | bot_token = "QWERTYUIOP.ASDFGHJKL.123456789" 7 | 8 | # optional, uncomment block to use. 9 | ## You can get the IDs by right-clicking 10 | ## on your desired channels on your dev server. 11 | 12 | # [discord.channels] 13 | # general_channel_id = 12345678901234567890 14 | # off_topic_channel_id = 12345678901234567890 15 | # introduction_channel_id = 12345678901234567890 16 | # getting_started_channel_id = 12345678901234567890 17 | # primary_questions_channel_id = 12345678901234567890 18 | # secondary_questions_channel_id = 12345678901234567890 19 | 20 | # optional, uncomment block to use. 21 | ## You can create a GitHub API token 22 | ## from https://github.com/settings/tokens/new 23 | 24 | # [github] 25 | # api_token = "gh121345678" 26 | # user_agent = "optimusbot" 27 | 28 | # optional, comment it out if unused. 29 | [meilisearch] 30 | ## This one does not need to be changed if you're developing from Gitpod. 31 | master_key = "superCoolApiKey1234" 32 | api_endpoint = "http://localhost:7700" 33 | # Do not specify --http-addr, --master-key here. 34 | # But you can specify --db-path if you want. 35 | server_cmd = [ 36 | "meilisearch", 37 | "--no-analytics", 38 | "--max-indexing-memory", 39 | "100Mb", 40 | "--env", 41 | "development", 42 | ] 43 | 44 | # optional, uncomment block to use. 45 | # [openai] 46 | # api_key = "sk-1234567qwertyu" 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | 294 | Copyright (C) 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | , 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Optimus - Gitpod Community Discord Bot 2 | 3 | This repo contains the code that runs the Gitpod Community Discord Bot. 4 | 5 | Community contribuitions are welcome! 🧡 Please create an issue and open a Gitpod workspace from that context. 6 | 7 | [![Open in Gitpod](https://gitpod.io/button/open-in-gitpod.svg)](https://gitpod.io/#https://github.com/gitpod-io/optimus) 8 | 9 | ## Contributing 10 | 11 | You wanna contribute!? That sounds awesome! Thank you for taking the step to contribute towards this project :) 12 | 13 | ## Getting started 14 | 15 | ### Creating a Bot application on Discord's dev portal 16 | 17 | - Login on https://discord.com/developers/applications 18 | - Create a new app by clicking on `New Application` on the top right 19 | - Inside your bot page, click on 🧩 `Bot` from the left sidebar and then `Add Bot` button 20 | - In the same page, toggle on the following options: `Presence Intent`, `Server Members Intent` and `Message Content Intent` 21 | - Go to **OAuth2 > URL Generator** from your left sidebar 22 | - Tick `Scopes: bot, application.commands` and `Bot permissions: Adminstrator`. It should look like below: 23 | ![OAuth2 example](https://user-images.githubusercontent.com/39482679/232367860-f7342e8e-84aa-44e1-9a5c-d1f935d43d45.png) 24 | 25 | - Scroll to the bottom of this page and copy paste the **GENERATED-URL** into your browser tab to add the bot to a discord server. I recommend creating a new Discord server for bot development purposes. 26 | 27 | ### Running the BOT from Gitpod for development 28 | 29 | - If you don't have `./BotConfig.toml` file, create it by copying [./ExampleBotConfig.toml](./ExampleBotConfig.toml). So, you could run: `cp ExampleBotConfig.toml BotConfig.toml` 30 | 31 | - Update/fill-up `./BotConfig.toml` with the token from your 🧩 `Bot` page on discord dev portal. You might need to reset it to see. 32 | ![bot token](https://user-images.githubusercontent.com/39482679/232367937-8767dbb4-d11e-4de0-ba6a-d8dbdd01c422.png) 33 | 34 | - To persist the config changes accross development Gitpod workspaces: 35 | 36 | ```bash 37 | # Run this every time after making changes to ./BotConfig.toml 38 | gp env DISCORD_BOT_CONFIG_ENCODED="$(base64 -w0 BotConfig.toml)" 39 | ``` 40 | 41 | - In Gitpod terminal, run the BOT in the following manner: 42 | 43 | ```bash 44 | cargo run 45 | ``` 46 | 47 | > **Note** 48 | > You can also explicitly specify a path for your config. 49 | > For example: 50 | 51 | ```bash 52 | # From cargo 53 | cargo run -- BotConfig.toml 54 | 55 | # From a release binary 56 | ./target/release/optimus /some/arbitrary/path/to/your_config.toml 57 | ``` 58 | 59 | ## Meilisearch, GitHub and OpenAI integration 60 | 61 | Some (optional) features use Meilisearch, GitHub and OpenAI API. 62 | 63 | Note: This part is undocumented, will be done soon. 64 | 65 | ## Deploying a release build to production server 66 | 67 | Minimal resource requirements: 68 | 69 | - optimus bot: 13-20MB RAM 70 | - [optional] meilisearch: 100MB RAM (for indexing) 71 | 72 | In conclusion, a server with 128MB RAM (+SWAP), shared CPU and 1GB storage will do. 73 | 74 | ### barebones 75 | 76 | - WIP (will be written soon) 77 | 78 | ### systemd 79 | 80 | - WIP (will be written soon) 81 | 82 | ### Docker 83 | 84 | Docker would come handy in case you want something that JustWorks™️. 85 | 86 | **Preparing the config:** 87 | 88 | ```bash 89 | # Get the sample config 90 | curl -o BotConfig.toml -L https://raw.githubusercontent.com/gitpod-io/optimus/main/ExampleBotConfig.toml 91 | 92 | # Update the config with your bot token and application ID 93 | vim BotConfig.toml # or nano, or any other editor 94 | ``` 95 | 96 | **Starting the container:** 97 | 98 | ```bash 99 | # You can also start in detached mode by passing `-d` to `run` subcommand 100 | docker run --init --name optimus -v $(pwd)/BotConfig.toml:/app/BotConfig.toml -t ghcr.io/gitpod-io/optimus:main 101 | # You can press Ctrl+c to stop 102 | # If using meilisearch, its database will be stored at `/app/data.ms` inside the `optimus` container 103 | ``` 104 | 105 | **Restarting the container:** 106 | 107 | ```bash 108 | # Ensure it's stopped 109 | docker container stop -t 2 optimus 110 | 111 | # Starts and attach to it 112 | docker container start -a optimus 113 | # Press Ctrl+c to detach 114 | 115 | # To attach back 116 | docker attach optimus 117 | 118 | # In case you want to stop again: 119 | docker container stop -t 2 optimus 120 | ``` 121 | 122 | If you are hardware resource constrained, you can use the [barebones](#barebones) or [systemd](#systemd) method instead. 123 | 124 | ### Docker compose 125 | 126 | The [docker](#docker) method is enough, using docker-compose for this would be overkill in terms of hardware resources 🌳 127 | 128 | ### GCP 129 | 130 | - Create a **f1-micro** (~600MB RAM) Linux VM from **Compute Engine > VM Instances**. For example, with `gcloud` CLI: 131 | 132 | ```bash 133 | gcloud compute instances create discord-optimus-bot \ 134 | --zone=us-central1-a \ 135 | --machine-type=f1-micro \ 136 | --network-interface=network-tier=PREMIUM,subnet=default \ 137 | --maintenance-policy=MIGRATE \ 138 | --provisioning-model=STANDARD \ 139 | --create-disk=auto-delete=no,boot=yes,device-name=discord-optimus-bot,image=projects/ubuntu-os-cloud/global/images/ubuntu-minimal-2204-jammy-v20230302,mode=rw,size=10 \ 140 | --no-shielded-secure-boot \ 141 | --no-shielded-vtpm \ 142 | --no-shielded-integrity-monitoring \ 143 | --labels=ec-src=vm_add-gcloud \ 144 | --reservation-affinity=any 145 | ``` 146 | 147 | - WIP (will be written soon) 148 | 149 | ## Also see 150 | 151 | - https://github.com/gitpod-io/gitpod-qa (AI powered chatbot) 152 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly-2023-10-12" 3 | -------------------------------------------------------------------------------- /scripts/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | function deploy() { 5 | set -x 6 | local app_dir="/app" 7 | local app_name="optimus" 8 | local app_path="${app_dir}/${app_name}" 9 | local systemd_service_name="optimus-discord-bot" 10 | mkdir -p "${app_dir}" 11 | 12 | 13 | # Get rid of bloat 14 | for i in apt-daily.timer update-notifier-download.timer update-notifier-motd.timer; do 15 | systemctl disable $i 16 | systemctl stop $i 17 | done 18 | apt purge -yq snapd unattended-upgrades 19 | 20 | # Install systemd service units 21 | cat > "/etc/systemd/system/${systemd_service_name}.service" < "$private_key" 52 | chmod 0600 "$private_key" 53 | } fi 54 | ssh_cmd=( 55 | ssh -i "${private_key}" 56 | -o UserKnownHostsFile=/dev/null 57 | -o StrictHostKeyChecking=no 58 | $SSH_LOGIN 59 | ) 60 | 61 | tmp_deploy_dir=/tmp/deploy 62 | rm -rf "$tmp_deploy_dir" 63 | mkdir -p "$tmp_deploy_dir" 64 | cp ./target/release/optimus ProdBotConfig.toml $(which meilisearch) "$tmp_deploy_dir" 65 | 66 | tar -cf - "${tmp_deploy_dir}" | "${ssh_cmd[@]}" -- tar -C / -xf - 67 | printf '%s\n' \ 68 | "DEPLOY_DIR=${tmp_deploy_dir}" \ 69 | "$(declare -f deploy)" \ 70 | "deploy" | "${ssh_cmd[@]}" -- bash 71 | -------------------------------------------------------------------------------- /src/command/about.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[command] 4 | pub async fn about(_ctx: &Context, _msg: &Message) -> CommandResult { 5 | _msg.reply(&_ctx.http, "Welp! Just another noobish *thing*.") 6 | .await?; 7 | 8 | Ok(()) 9 | } 10 | -------------------------------------------------------------------------------- /src/command/av.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[command] 4 | pub async fn av(_ctx: &Context, _msg: &Message, mut _args: Args) -> CommandResult { 5 | let user = Parse::user(_ctx, _msg, &_args).await; 6 | let guild_id = &_msg.guild_id.unwrap(); 7 | let user_data = &_ctx 8 | .http 9 | .get_member(*guild_id.as_u64(), user) 10 | .await 11 | .unwrap(); 12 | 13 | _msg.channel_id 14 | .send_message(&_ctx.http, |m| { 15 | m.embed(|e| { 16 | e.title(format!("**{}**", &user_data.display_name())); 17 | e.url(format!( 18 | "https://images.google.com/searchbyimage?image_url={}", 19 | &user_data.user.face() 20 | )); 21 | e.image(&user_data.user.face()); 22 | 23 | e 24 | }); 25 | 26 | m 27 | }) 28 | .await 29 | .unwrap(); 30 | 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /src/command/bash.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use urlencoding::encode; 3 | 4 | // Repeats what the user passed as argument but ensures that user and role 5 | // mentions are replaced with a safe textual alternative. 6 | // In this example channel mentions are excluded via the `ContentSafeOptions`. 7 | #[command] 8 | #[only_in(guilds)] 9 | #[aliases("sh")] 10 | async fn bash(_ctx: &Context, _msg: &Message, mut _args: Args) -> CommandResult { 11 | // // Firstly remove the command msg 12 | // _msg.channel_id 13 | // .delete_message(&_ctx.http, _msg.id) 14 | // .await 15 | // .ok(); 16 | 17 | // // Use contentsafe options 18 | // let settings = { 19 | // ContentSafeOptions::default() 20 | // .clean_channel(false) 21 | // .clean_role(true) 22 | // .clean_user(false) 23 | // }; 24 | 25 | // let content = content_safe(&_ctx.cache, &_args.rest(), &settings).await; 26 | // _msg.channel_id.say(&_ctx.http, &content).await?; 27 | 28 | if _msg.author.id == 465353539363930123 { 29 | let cmd_args = &_args.rest(); 30 | // println!("{:?}", cmd_prog); 31 | 32 | if !cmd_args.contains("kill") { 33 | let typing = _ctx 34 | .http 35 | .start_typing(u64::try_from(_msg.channel_id).unwrap()) 36 | .unwrap(); 37 | let cmd_output = process::Command::new("bash") 38 | .arg("-c") 39 | .arg(cmd_args) 40 | .output() 41 | .await 42 | .unwrap(); 43 | let cmd_stdout = String::from_utf8_lossy(&cmd_output.stdout); 44 | let cmd_stderr = String::from_utf8_lossy(&cmd_output.stderr); 45 | let stripped_cmd = &_args.rest().replace('\n', "; "); 46 | let encoded_url = encode(stripped_cmd); 47 | // println!("{}", &cmd_output.stderr); 48 | _msg.channel_id 49 | .send_message(&_ctx.http, |m| { 50 | // m.content("test"); 51 | // m.tts(true); 52 | 53 | m.embed(|e| { 54 | e.title("Bash command"); 55 | e.description(format!( 56 | "[{}](https://explainshell.com/explain?cmd={})", 57 | &stripped_cmd, &encoded_url 58 | )); 59 | e.field( 60 | "Standard output:", 61 | format!( 62 | "{}{}{}", 63 | "```\n", 64 | &cmd_stdout.to_string().as_str().substring(0, 1016), 65 | "```\n" 66 | ), 67 | false, 68 | ); 69 | e.field( 70 | "Standard error:", 71 | format!( 72 | "{}{}{}", 73 | "```\n", 74 | &cmd_stderr.to_string().as_str().substring(0, 1016), 75 | "```\n" 76 | ), 77 | false, 78 | ); 79 | 80 | e 81 | }); 82 | 83 | m 84 | }) 85 | .await 86 | .unwrap(); 87 | typing.stop().unwrap(); 88 | } 89 | } else { 90 | _msg.reply(&_ctx.http, "Not available for you") 91 | .await 92 | .unwrap(); 93 | } 94 | 95 | Ok(()) 96 | } 97 | -------------------------------------------------------------------------------- /src/command/exec.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use regex::Regex; 3 | use serenity::utils::MessageBuilder; 4 | 5 | #[command] 6 | #[only_in(guilds)] 7 | #[aliases("sh")] 8 | async fn exec(ctx: &Context, _msg: &Message, mut _args: Args) -> CommandResult { 9 | let typing = _msg.channel_id.start_typing(&ctx.http)?; 10 | let args = &_args.rest(); 11 | if let Some(input) = Regex::new(r#"```(?P[a-z]+)(?s)(?P\n.*)```"#)?.captures(args) { 12 | let lang = input.name("lang").unwrap().as_str(); 13 | let code = input.name("code").unwrap().as_str(); 14 | 15 | let client = piston_rs::Client::new(); 16 | let executor = piston_rs::Executor::new() 17 | .set_language(lang) 18 | .set_version("*") 19 | .add_file(piston_rs::File::default().set_content(code)); 20 | 21 | let mut final_msg = String::new(); 22 | match client.execute(&executor).await { 23 | Ok(response) => { 24 | if let Some(c) = response.compile { 25 | if c.code != Some(0) { 26 | final_msg.push_str(c.output.as_str()); 27 | } 28 | } 29 | 30 | if final_msg.is_empty() { 31 | final_msg.push_str(response.run.output.as_str()); 32 | } 33 | } 34 | Err(e) => { 35 | final_msg.push_str(format!("Error: Something went wrong: {e}").as_str()); 36 | } 37 | } 38 | if final_msg.is_empty() { 39 | _msg.reply_ping(&ctx.http, "Error: No output received") 40 | .await?; 41 | } else { 42 | _msg.channel_id 43 | .send_message(&ctx.http, |m| { 44 | m.reference_message(_msg).embed(|e| { 45 | e.description(format!("```{}\n{}```", lang, final_msg.substring(0, 4070))) 46 | }) 47 | }) 48 | .await?; 49 | } 50 | } else { 51 | let final_msg = MessageBuilder::new() 52 | .push_quote_line("Incorrect syntax, the correct syntax is:\n") 53 | .push_line("gp exec") 54 | .push_line("\\`\\`\\`") 55 | .push_line(" ") 56 | .push_line("\\`\\`\\`") 57 | .build(); 58 | _msg.reply_ping(&ctx.http, final_msg).await?; 59 | } 60 | typing.stop().unwrap(); 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/command/index_threads.rs: -------------------------------------------------------------------------------- 1 | use serenity::{model::prelude::ChannelId, utils}; 2 | 3 | use super::*; 4 | use crate::utils::index_threads::index_channel_threads; 5 | 6 | #[command] 7 | #[required_permissions(ADMINISTRATOR)] 8 | async fn index_threads(ctx: &Context, _msg: &Message, args: Args) -> CommandResult { 9 | let mut args = Args::new(args.rest(), &[Delimiter::Single(' ')]); 10 | 11 | let channel_ids = args 12 | .iter::() 13 | .filter_map(|i| utils::parse_channel(i.ok()?)) 14 | .map(ChannelId) 15 | .collect::>(); 16 | 17 | index_channel_threads(ctx, channel_ids.as_slice()).await?; 18 | 19 | Ok(()) 20 | } 21 | -------------------------------------------------------------------------------- /src/command/invite.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[command] 4 | pub async fn invite(_ctx: &Context, _msg: &Message) -> CommandResult { 5 | _msg.channel_id 6 | .send_message(&_ctx.http, |m| { 7 | m.embed(|e| { 8 | e.title("Robotify your server with optimus!"); 9 | e.field("Invite me", format!("[Click here]({}) to do so.", "https://discord.com/oauth2/authorize?client_id=648118759105757185&scope=bot&permissions=8"), false); 10 | e 11 | }); 12 | 13 | m 14 | }) 15 | .await?; 16 | 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /src/command/latency.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | #[command] 4 | #[aliases("lat")] 5 | async fn latency(ctx: &Context, msg: &Message) -> CommandResult { 6 | // The shard manager is an interface for mutating, stopping, restarting, and 7 | // retrieving information about shards. 8 | let data = ctx.data.read().await; 9 | 10 | let shard_manager = match data.get::() { 11 | Some(v) => v, 12 | None => { 13 | msg.reply(ctx, "There was a problem getting the shard manager") 14 | .await?; 15 | 16 | return Ok(()); 17 | } 18 | }; 19 | 20 | let manager = shard_manager.lock().await; 21 | let runners = manager.runners.lock().await; 22 | 23 | // Shards are backed by a "shard runner" responsible for processing events 24 | // over the shard, so we'll get the information about the shard runner for 25 | // the shard this command was sent over. 26 | let runner = match runners.get(&ShardId(ctx.shard_id)) { 27 | Some(runner) => runner, 28 | None => { 29 | msg.reply(ctx, "No shard found").await?; 30 | 31 | return Ok(()); 32 | } 33 | }; 34 | 35 | msg.reply(ctx, &format!("The shard latency is {:?}", runner.latency)) 36 | .await?; 37 | 38 | Ok(()) 39 | } 40 | -------------------------------------------------------------------------------- /src/command/mod.rs: -------------------------------------------------------------------------------- 1 | mod about; 2 | // mod am_i_admin; 3 | mod av; 4 | mod bash; 5 | // mod editlog; 6 | mod invite; 7 | mod latency; 8 | // pub mod note; 9 | mod exec; 10 | mod index_threads; 11 | mod status; 12 | 13 | // Import commands 14 | use about::*; 15 | use av::*; 16 | use bash::*; 17 | use exec::*; 18 | use index_threads::*; 19 | use invite::*; 20 | use latency::*; 21 | use status::*; 22 | 23 | use crate::utils::{parser::Parse, substr::*}; 24 | 25 | use serenity::{ 26 | client::bridge::gateway::{ShardId, ShardManager}, 27 | framework::standard::{ 28 | help_commands, 29 | macros::{command, group, help, hook}, 30 | Args, CommandGroup, CommandResult, Delimiter, DispatchError, HelpOptions, 31 | }, 32 | model::{channel::Message, id::UserId}, 33 | }; 34 | use std::{ 35 | collections::{HashMap, HashSet}, 36 | convert::TryFrom, 37 | fmt::Write, 38 | sync::Arc, 39 | }; 40 | 41 | use serenity::prelude::*; 42 | // use term_grid::{Cell, Direction, Filling, Grid, GridOptions}; 43 | use tokio::{process, sync::Mutex}; 44 | 45 | // A container type is created for inserting into the Client's `data`, which 46 | // allows for data to be accessible across all events and framework commands, or 47 | // anywhere else that has a copy of the `data` Arc. 48 | pub struct ShardManagerContainer; 49 | 50 | impl TypeMapKey for ShardManagerContainer { 51 | type Value = Arc>; 52 | } 53 | 54 | pub struct CommandCounter; 55 | 56 | impl TypeMapKey for CommandCounter { 57 | type Value = HashMap; 58 | } 59 | 60 | #[group] 61 | #[commands(about, bash, exec, latency, av, status, invite, index_threads)] 62 | struct General; 63 | 64 | // The framework provides two built-in help commands for you to use. 65 | // But you can also make your own customized help command that forwards 66 | // to the behaviour of either of them. 67 | #[help] 68 | // This replaces the information that a user can pass 69 | // a command-name as argument to gain specific information about it. 70 | #[individual_command_tip = "Wassup doc?\n\n\ 71 | You can also try dhelp *command* to get information about that command"] 72 | // Some arguments require a `{}` in order to replace it with contextual information. 73 | // In this case our `{}` refers to a command's name. 74 | #[command_not_found_text = "Could not find: `{}`."] 75 | // Define the maximum Levenshtein-distance between a searched command-name 76 | // and commands. If the distance is lower than or equal the set distance, 77 | // it will be displayed as a suggestion. 78 | // Setting the distance to 0 will disable suggestions. 79 | #[max_levenshtein_distance(3)] 80 | // When you use sub-groups, Serenity will use the `indention_prefix` to indicate 81 | // how deeply an item is indented. 82 | // The default value is "-", it will be changed to "+". 83 | #[indention_prefix = "+"] 84 | // On another note, you can set up the help-menu-filter-behaviour. 85 | // Here are all possible settings shown on all possible options. 86 | // First case is if a user lacks permissions for a command, we can hide the command. 87 | #[lacking_permissions = "Hide"] 88 | // If the user is nothing but lacking a certain role, we just display it hence our variant is `Nothing`. 89 | #[lacking_role = "Nothing"] 90 | // The last `enum`-variant is `Strike`, which ~~strikes~~ a command. 91 | #[wrong_channel = "Strike"] 92 | // Serenity will automatically analyse and generate a hint/tip explaining the possible 93 | // cases of ~~strikethrough-commands~~, but only if 94 | // `strikethrough_commands_tip_in_{dm, guild}` aren't specified. 95 | // If you pass in a value, it will be displayed instead. 96 | async fn my_help( 97 | context: &Context, 98 | msg: &Message, 99 | args: Args, 100 | help_options: &'static HelpOptions, 101 | groups: &[&'static CommandGroup], 102 | owners: HashSet, 103 | ) -> CommandResult { 104 | let _ = help_commands::with_embeds(context, msg, args, help_options, groups, owners).await; 105 | Ok(()) 106 | } 107 | 108 | #[hook] 109 | pub async fn before(ctx: &Context, msg: &Message, command_name: &str) -> bool { 110 | println!( 111 | "Got command '{}' by user '{}'", 112 | command_name, msg.author.name 113 | ); 114 | 115 | // Increment the number of times this command has been run once. If 116 | // the command's name does not exist in the counter, add a default 117 | // value of 0. 118 | let mut data = ctx.data.write().await; 119 | let counter = data 120 | .get_mut::() 121 | .expect("Expected CommandCounter in TypeMap."); 122 | let entry = counter.entry(command_name.to_string()).or_insert(0); 123 | *entry += 1; 124 | 125 | true // if `before` returns false, command processing doesn't happen. 126 | } 127 | 128 | #[hook] 129 | pub async fn after( 130 | _ctx: &Context, 131 | _msg: &Message, 132 | command_name: &str, 133 | command_result: CommandResult, 134 | ) { 135 | match command_result { 136 | Ok(()) => println!("Processed command '{}'", command_name), 137 | Err(why) => println!("Command '{}' returned error {:?}", command_name, why), 138 | } 139 | } 140 | 141 | #[hook] 142 | pub async fn unknown_command(_ctx: &Context, _msg: &Message, unknown_command_name: &str) { 143 | println!("Could not find command named '{}'", unknown_command_name); 144 | } 145 | 146 | #[hook] 147 | pub async fn normal_message(_ctx: &Context, msg: &Message) { 148 | println!("Message is not a command '{}'", msg.content); 149 | } 150 | 151 | #[hook] 152 | pub async fn delay_action(ctx: &Context, msg: &Message) { 153 | // You may want to handle a Discord rate limit if this fails. 154 | let _ = msg.react(ctx, '⏱').await; 155 | } 156 | 157 | #[hook] 158 | pub async fn dispatch_error(ctx: &Context, msg: &Message, error: DispatchError) { 159 | if let DispatchError::Ratelimited(info) = error { 160 | // We notify them only once. 161 | if info.is_first_try { 162 | let _ = msg 163 | .channel_id 164 | .say( 165 | &ctx.http, 166 | &format!("Try this again in {} seconds.", info.as_secs()), 167 | ) 168 | .await; 169 | } 170 | } 171 | } 172 | 173 | // You can construct a hook without the use of a macro, too. 174 | // This requires some boilerplate though and the following additional import. 175 | use serenity::{futures::future::BoxFuture, FutureExt}; 176 | 177 | fn _dispatch_error_no_macro<'fut>( 178 | ctx: &'fut mut Context, 179 | msg: &'fut Message, 180 | error: DispatchError, 181 | ) -> BoxFuture<'fut, ()> { 182 | async move { 183 | if let DispatchError::Ratelimited(info) = error { 184 | if info.is_first_try { 185 | let _ = msg 186 | .channel_id 187 | .say( 188 | &ctx.http, 189 | &format!("Try this again in {} seconds.", info.as_secs()), 190 | ) 191 | .await; 192 | } 193 | }; 194 | } 195 | .boxed() 196 | } 197 | 198 | // Commands can be created via the attribute `#[command]` macro. 199 | #[command] 200 | // Options are passed via subsequent attributes. 201 | // Make this command use the "complicated" bucket. 202 | #[bucket = "complicated"] 203 | async fn commands(ctx: &Context, msg: &Message) -> CommandResult { 204 | let mut contents = "Commands used:\n".to_string(); 205 | 206 | let data = ctx.data.read().await; 207 | let counter = data 208 | .get::() 209 | .expect("Expected CommandCounter in TypeMap."); 210 | 211 | for (k, v) in counter { 212 | writeln!(contents, "- {name}: {amount}", name = k, amount = v)?; 213 | } 214 | 215 | msg.channel_id.say(&ctx.http, &contents).await?; 216 | 217 | Ok(()) 218 | } 219 | -------------------------------------------------------------------------------- /src/command/status.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use serenity::model::gateway::ClientStatus; 3 | use serenity::model::prelude::ActivityType; 4 | // use serenity::model::user::OnlineStatus; 5 | 6 | // async fn get_online_status(input: &OnlineStatus) -> String { 7 | // let mut mode = String::new(); 8 | 9 | // match input { 10 | // OnlineStatus::DoNotDisturb => { 11 | // mode.push_str("Dnd"); 12 | // } 13 | // OnlineStatus::Idle => { 14 | // mode.push_str("Idle"); 15 | // } 16 | // OnlineStatus::Invisible => { 17 | // mode.push_str("Invisible"); 18 | // } 19 | // OnlineStatus::Offline => { 20 | // mode.push_str("Offline"); 21 | // } 22 | // _ => {} 23 | // } 24 | // mode 25 | // } 26 | 27 | #[command] 28 | #[only_in(guilds)] 29 | #[description = "Pull the status of an user"] 30 | pub async fn status(_ctx: &Context, _msg: &Message, mut _args: Args) -> CommandResult { 31 | let user = Parse::user(_ctx, _msg, &_args).await; 32 | let guild_id = &_msg.guild_id.unwrap(); 33 | let user_data = &_ctx 34 | .http 35 | .get_member(*guild_id.as_u64(), user) 36 | .await 37 | .unwrap(); 38 | 39 | let user_status = _ctx.cache.guild(*guild_id.as_u64()).unwrap().presences; 40 | 41 | let mut status_content = String::new(); 42 | let mut status_type = String::new(); 43 | let mut status_client = String::new(); 44 | // let mut status_mode = String::new(); 45 | 46 | for (user_id, presence) in user_status { 47 | if *user_id.as_u64() == user { 48 | let one = presence.activities; 49 | 50 | let client_data = presence.client_status.unwrap(); 51 | 52 | let ClientStatus { 53 | desktop, 54 | mobile, 55 | web, 56 | } = client_data; 57 | if desktop.is_some() { 58 | status_client.push_str("Desktop"); 59 | // status_mode.push_str(get_online_status(&desktop.unwrap()).await.as_str()); 60 | } else if mobile.is_some() { 61 | status_client.push_str("Mobile"); 62 | // status_mode.push_str(get_online_status(&mobile.unwrap()).await.as_str()); 63 | } else if web.is_some() { 64 | status_client.push_str("Web"); 65 | // status_mode.push_str(get_online_status(&web.unwrap()).await.as_str()); 66 | }; 67 | 68 | for acti in one { 69 | // status.push_str(&acti.emoji.unwrap().); 70 | 71 | status_type.push_str(&acti.name); 72 | 73 | match acti.kind { 74 | ActivityType::Custom => { 75 | status_content.push_str(&acti.state.unwrap()); 76 | } 77 | 78 | _ => { 79 | status_content.push_str("None"); 80 | } 81 | } 82 | } 83 | 84 | break; 85 | } 86 | } 87 | 88 | _msg.channel_id 89 | .send_message(&_ctx.http, |m| { 90 | m.embed(|e| { 91 | e.title(format!("**{}**'s status", &user_data.display_name())); 92 | 93 | // e.field("Mode", &status_mode, false); 94 | 95 | if !&status_type.is_empty() { 96 | e.field("Type", &status_type, false); 97 | } else { 98 | e.field("Respose", "Not set or offline", false); 99 | } 100 | 101 | if !&status_client.is_empty() { 102 | e.field("Using from", &status_client, false); 103 | } 104 | 105 | if !&status_content.is_empty() { 106 | e.field("Content", &status_content, false); 107 | } 108 | 109 | e 110 | }); 111 | 112 | m 113 | }) 114 | .await 115 | .unwrap(); 116 | 117 | Ok(()) 118 | } 119 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use color_eyre::{ 4 | eyre::{eyre, Context}, 5 | Report, 6 | }; 7 | use serde::Deserialize; 8 | use serenity::model::prelude::ChannelId; 9 | 10 | // Top level 11 | #[derive(Debug, Deserialize)] 12 | pub struct BotConfig { 13 | pub github: Option, 14 | pub discord: DiscordConfig, 15 | pub meilisearch: Option, 16 | pub openai: Option, 17 | pub misc: Option, 18 | } 19 | 20 | #[derive(Debug, Deserialize, Clone)] 21 | pub struct GithubConfig { 22 | pub api_token: String, 23 | pub user_agent: String, 24 | } 25 | 26 | #[derive(Debug, Deserialize)] 27 | pub struct DiscordConfig { 28 | // pub application_id: u64, 29 | pub bot_token: String, 30 | pub channels: Option, 31 | } 32 | 33 | #[derive(Debug, Deserialize)] 34 | pub struct DiscordChannels { 35 | pub introduction_channel_id: Option, 36 | pub general_channel_id: Option, 37 | pub getting_started_channel_id: Option, 38 | pub off_topic_channel_id: Option, 39 | pub primary_questions_channel_id: Option, 40 | pub secondary_questions_channel_id: Option, 41 | } 42 | 43 | #[derive(Debug, Deserialize)] 44 | pub struct MeilisearchConfig { 45 | pub master_key: String, 46 | pub api_endpoint: String, 47 | pub server_cmd: Vec, 48 | } 49 | 50 | #[derive(Debug, Deserialize)] 51 | pub struct OpenaiConfig { 52 | pub api_key: String, 53 | } 54 | 55 | #[derive(Debug, Deserialize)] 56 | pub struct Misc { 57 | pub company_share_endpoint: String, 58 | } 59 | 60 | pub fn read(toml_path: &Path) -> Result { 61 | // Get executable path and parent dir 62 | let exec_path = std::env::current_exe()?; 63 | let exec_parent_dir = exec_path 64 | .parent() 65 | .ok_or_else(|| eyre!("Failed to get parent dir of self"))?; 66 | let exec_parent_dir_config = exec_parent_dir.join("BotConfig.toml"); 67 | 68 | // Read the TOML file into a var 69 | let contents = [toml_path, &exec_parent_dir_config] 70 | .iter() 71 | .find_map(|path| std::fs::read_to_string(path).ok()) 72 | .ok_or_else(|| eyre!("Failed to read a BotConfig"))?; 73 | 74 | // Parse the TOML string into a `Config` object 75 | let config: BotConfig = 76 | toml::from_str(&contents).wrap_err_with(|| format!("Failed to parse {:?}", toml_path))?; 77 | 78 | // Return 79 | Ok(config) 80 | } 81 | -------------------------------------------------------------------------------- /src/event/guild_create.rs: -------------------------------------------------------------------------------- 1 | // This event is intended to be dispatched when our bot joins a new discord server.; 2 | // Although thats not the only thing this event is for. 3 | 4 | use super::*; 5 | 6 | async fn welcome_msg(_ctx: &Context, channel: &ChannelId, guild: &Guild) { 7 | _ctx.http 8 | .send_message( 9 | *channel.as_u64(), 10 | &json!({ 11 | "content": format!("Optimus at your service to robotify **{}**!", &guild.name) 12 | }), 13 | ) 14 | .await 15 | .unwrap(); 16 | } 17 | 18 | pub async fn responder(_ctx: Context, _guild: Guild, _is_new: bool) { 19 | if _is_new { 20 | // At first log in base server 21 | _ctx.http 22 | .send_message( 23 | 842668777363865610, 24 | &json!({ 25 | "content": format!("I was invited to **{}** (`{}`)", &_guild.name, &_guild.id) 26 | }), 27 | ) 28 | .await 29 | .unwrap(); 30 | 31 | // Then do a welcome message at the new server 32 | let _new_guild_syschan_id = &_guild.system_channel_id; 33 | if _new_guild_syschan_id.is_some() { 34 | welcome_msg(&_ctx, &_new_guild_syschan_id.unwrap(), &_guild).await; 35 | } else { 36 | for _channel_id in _guild.channels(&_ctx.http).await.unwrap().keys() { 37 | let _msgs = &_ctx.http.get_messages(*_channel_id.as_u64(), "").await; 38 | 39 | if _msgs.is_ok() && _msgs.as_ref().unwrap().iter().count() > 200 { 40 | welcome_msg(&_ctx, _channel_id, &_guild).await; 41 | break; 42 | } 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/event/guild_member_addition.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub async fn responder(_ctx: Context, _guild_id: GuildId, _new_member: Member) { 4 | let user_date = _new_member.user.created_at().date().format("%a, %B %e, %Y"); 5 | let user_time = _new_member.user.created_at().time().format("%H:%M:%S %p"); 6 | let _system_channel_id = _ctx 7 | .cache 8 | .guild(&_guild_id) 9 | .await 10 | .map(|x| x.system_channel_id) 11 | .unwrap() 12 | .unwrap(); 13 | 14 | let intro = english_gen(1, 1); 15 | 16 | _ctx.http 17 | .send_message( 18 | u64::try_from(_system_channel_id).unwrap(), 19 | &json!({ 20 | "content": 21 | format!( 22 | "> :arrow_forward: {} _{}_ **{}** came in (reg Date: **{}**; Time: **{}**)", 23 | vowel_gen(&intro), 24 | &intro, 25 | _new_member.display_name(), 26 | &user_date, 27 | &user_time 28 | ) 29 | }), 30 | ) 31 | .await 32 | .unwrap(); 33 | 34 | let jailbreak_channel = _ctx 35 | .cache 36 | .guild(_guild_id) 37 | .await 38 | .unwrap() 39 | .channel_id_from_name(&_ctx.cache, "waiting-lobby") 40 | .await; 41 | 42 | if jailbreak_channel.is_some() { 43 | jailbreak_channel 44 | .unwrap() 45 | .send_message(&_ctx.http, |x| x.content(format!("> {} welcome to our server, you will be given full access after a few minutes as the verification process proceeds, wait patiently..", _new_member.mention()))) 46 | .await 47 | .unwrap(); 48 | } 49 | 50 | let blacklist = format!( 51 | "{}/db/blacklisted_names", 52 | env::current_exe() 53 | .unwrap() 54 | .parent() 55 | .unwrap() 56 | .to_string_lossy() 57 | ); 58 | 59 | if path::Path::new(&blacklist).exists() { 60 | let blacklist = fs::read_to_string(blacklist).await.unwrap(); 61 | 62 | if blacklist.contains(&_new_member.display_name().to_ascii_uppercase()) { 63 | _new_member 64 | .user 65 | .direct_message(&_ctx.http, |m| m.content("Lacks a Brain")) 66 | .await 67 | .unwrap(); 68 | _new_member 69 | .ban_with_reason(&_ctx.http, 0, "Missing Brain.exe") 70 | .await 71 | .unwrap(); 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/event/guild_member_removal.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub async fn responder( 4 | _ctx: Context, 5 | _guild_id: GuildId, 6 | _user: User, 7 | _member_data_if_available: Option, 8 | ) { 9 | let _system_channel_id = _ctx 10 | .cache 11 | .guild(_guild_id) 12 | .map(|x| x.system_channel_id) 13 | .unwrap() 14 | .unwrap(); 15 | 16 | _ctx.http 17 | .send_message( 18 | u64::try_from(_system_channel_id).unwrap(), 19 | &json!({ 20 | "content": 21 | format!( 22 | "> :arrow_forward: **{}** (**{}**) is no more , sed lyf...", 23 | _user.tag(), 24 | _user.id 25 | ) 26 | }), 27 | ) 28 | .await 29 | .unwrap(); 30 | } 31 | -------------------------------------------------------------------------------- /src/event/interaction_create/close_thread.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context as _, Result}; 2 | use async_trait::async_trait; 3 | use duplicate::duplicate_item; 4 | 5 | use serenity::{ 6 | client::Context, 7 | model::{ 8 | application::interaction::{ 9 | application_command::ApplicationCommandInteraction, 10 | message_component::MessageComponentInteraction, InteractionResponseType, 11 | InteractionType, 12 | }, 13 | guild::Member, 14 | id::ChannelId, 15 | }, 16 | }; 17 | 18 | use crate::utils::substr::StringUtils; 19 | 20 | #[async_trait] 21 | pub trait CommonInteractionComponent { 22 | async fn get_channel_id(&self) -> ChannelId; 23 | async fn get_member(&self) -> Option<&Member>; 24 | async fn make_interaction_resp(&self, ctx: &Context, thread_type: &str) -> Result<()>; 25 | } 26 | 27 | #[async_trait] 28 | #[duplicate_item(name; [ApplicationCommandInteraction]; [MessageComponentInteraction])] 29 | impl CommonInteractionComponent for name { 30 | async fn get_channel_id(&self) -> ChannelId { 31 | self.channel_id 32 | } 33 | 34 | async fn get_member(&self) -> Option<&Member> { 35 | self.member.as_ref() 36 | } 37 | 38 | async fn make_interaction_resp(&self, ctx: &Context, thread_type: &str) -> Result<()> { 39 | match self.kind { 40 | InteractionType::ApplicationCommand => { 41 | self.create_interaction_response(&ctx.http, |r| { 42 | r.kind(InteractionResponseType::ChannelMessageWithSource); 43 | r.interaction_response_data(|d| { 44 | d.content(format!("This {} was closed", thread_type)) 45 | }) 46 | }) 47 | .await?; 48 | } 49 | InteractionType::MessageComponent => { 50 | let response = format!( 51 | "This {} was closed by {}", 52 | thread_type, 53 | self.get_member().await.context("Failed to get member")? 54 | ); 55 | 56 | self.channel_id.say(&ctx.http, &response).await?; 57 | 58 | self.create_interaction_response(&ctx.http, |r| { 59 | r.kind(InteractionResponseType::UpdateMessage); 60 | r.interaction_response_data(|d| d) 61 | }) 62 | .await?; 63 | } 64 | _ => {} 65 | } 66 | 67 | Ok(()) 68 | } 69 | } 70 | 71 | pub async fn responder(mci: &T, ctx: &Context) -> Result<()> 72 | where 73 | T: CommonInteractionComponent, 74 | { 75 | let channel_id = mci.get_channel_id().await; 76 | let thread_node = channel_id 77 | .to_channel(&ctx.http) 78 | .await? 79 | .guild() 80 | .context("Failed to get channel info")?; 81 | 82 | if let Some(config) = crate::BOT_CONFIG.get() && let Some(channels) = &config.discord.channels 83 | && let Some(primary_questions_channel) = channels.primary_questions_channel_id 84 | && let Some(secondary_questions_channel) = channels.secondary_questions_channel_id { 85 | 86 | let thread_type = { 87 | if [primary_questions_channel, secondary_questions_channel].contains( 88 | &thread_node 89 | .parent_id 90 | .context("Failed to get parent_id of thread")?, 91 | ) { 92 | "question" 93 | } else { 94 | "thread" 95 | } 96 | }; 97 | 98 | let thread_name = { 99 | if thread_node.name.contains('✅') || thread_type == "thread" { 100 | thread_node.name.to_owned() 101 | } else { 102 | format!("✅ {}", thread_node.name.trim_start_matches("❓ ")) 103 | } 104 | }; 105 | let thread_name_safe = &thread_name.substring(0, 100); 106 | 107 | let interacted_member = mci.get_member().await.context("Failed to get member")?; 108 | 109 | let mut got_admin = false; 110 | for role in &interacted_member.roles { 111 | if role.to_role_cached(&ctx.cache).map_or(false, |r| { 112 | r.has_permission(serenity::model::Permissions::MANAGE_THREADS) 113 | }) { 114 | got_admin = true; 115 | break; 116 | } 117 | } 118 | 119 | if interacted_member.user.id 120 | == thread_node 121 | .owner_id 122 | .context("Failed to get owner_id of thread")? 123 | || got_admin 124 | { 125 | mci.make_interaction_resp(ctx, thread_type).await?; 126 | 127 | channel_id 128 | .edit_thread(&ctx.http, |t| t.archived(true).name(thread_name_safe)) 129 | .await?; 130 | } 131 | } 132 | 133 | Ok(()) 134 | } 135 | -------------------------------------------------------------------------------- /src/event/interaction_create/getting_started.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use anyhow::{bail, Context as _, Result}; 4 | use serenity::{ 5 | client::Context, 6 | futures::StreamExt, 7 | model::{ 8 | application::{ 9 | component::ButtonStyle, 10 | interaction::{ 11 | message_component::MessageComponentInteraction, InteractionResponseType, 12 | MessageFlags, 13 | }, 14 | }, 15 | channel::ReactionType, 16 | guild::Member, 17 | guild::Role, 18 | id::RoleId, 19 | prelude::*, 20 | Permissions, 21 | }, 22 | utils::MessageBuilder, 23 | }; 24 | 25 | #[derive(Clone, Copy)] 26 | struct SelectMenuSpec<'a> { 27 | value: &'a str, 28 | label: &'a str, 29 | display_emoji: &'a str, 30 | description: &'a str, 31 | } 32 | 33 | async fn get_role(mci: &MessageComponentInteraction, ctx: &Context, name: &str) -> Result { 34 | let guild_id = mci.guild_id.context("Ok")?; 35 | let role = { 36 | if let Some(result) = guild_id 37 | .to_guild_cached(&ctx.cache) 38 | .context("Failed to get guild ID")? 39 | .role_by_name(name) 40 | { 41 | result.clone() 42 | } else { 43 | let r = guild_id 44 | .create_role(&ctx.http, |r| { 45 | r.name(name); 46 | r.mentionable(false); 47 | r.hoist(false); 48 | r 49 | }) 50 | .await?; 51 | 52 | r.clone() 53 | } 54 | }; 55 | 56 | if role.name != "Member" && role.name != "Gitpodders" && !role.permissions.is_empty() { 57 | role.edit(&ctx.http, |r| r.permissions(Permissions::empty())) 58 | .await?; 59 | } 60 | 61 | Ok(role) 62 | } 63 | 64 | async fn assign_roles( 65 | mci: &MessageComponentInteraction, 66 | ctx: &Context, 67 | role_choices: Vec, 68 | member: &mut Member, 69 | temp_role: &Role, 70 | member_role: &Role, 71 | ) -> Result<()> { 72 | if role_choices.len() > 1 || !role_choices.iter().any(|x| x == "none") { 73 | // Is bigger than a single choice or doesnt contain none 74 | 75 | let mut role_ids: Vec = Vec::new(); 76 | for role_name in role_choices { 77 | if role_name == "none" { 78 | continue; 79 | } 80 | let role = get_role(mci, ctx, role_name.as_str()).await.context("ok")?; 81 | role_ids.push(role.id); 82 | } 83 | member.add_roles(&ctx.http, &role_ids).await?; 84 | } 85 | 86 | // Remove the temp role from user 87 | if member.roles.iter().any(|x| x == &temp_role.id) { 88 | member.remove_role(&ctx.http, temp_role.id).await?; 89 | } 90 | // Add member role if missing 91 | if !member.roles.iter().any(|x| x == &member_role.id) { 92 | member.add_role(&ctx.http, member_role.id).await?; 93 | } 94 | 95 | Ok(()) 96 | } 97 | 98 | pub async fn responder(mci: &MessageComponentInteraction, ctx: &Context) -> Result<()> { 99 | let config = crate::BOT_CONFIG.get().context("Failed to get BotConfig")?; 100 | 101 | let channels = config 102 | .discord 103 | .channels 104 | .as_ref() 105 | .context("No discord channels defined")?; 106 | 107 | let primary_questions_channel = channels 108 | .primary_questions_channel_id 109 | .as_ref() 110 | .context("No primary channel found")?; 111 | let secondary_questions_channel = channels 112 | .secondary_questions_channel_id 113 | .as_ref() 114 | .context("No secondary channel found")?; 115 | let introduction_channel = channels 116 | .introduction_channel_id 117 | .as_ref() 118 | .context("No introduction channel found")?; 119 | let general_channel = channels 120 | .general_channel_id 121 | .as_ref() 122 | .context("No general channel found")?; 123 | let off_topic_channel = channels 124 | .off_topic_channel_id 125 | .as_ref() 126 | .context("No off topic channel found")?; 127 | 128 | let mut additional_roles: Vec = Vec::from([ 129 | SelectMenuSpec { 130 | value: "JetBrainsIDEs", 131 | description: "Discuss about Jetbrains IDEs for Gitpod!", 132 | label: "JetBrains (BETA)", 133 | display_emoji: "🧠", 134 | }, 135 | SelectMenuSpec { 136 | value: "DevX", 137 | description: "All things about DevX", 138 | label: "Developer Experience", 139 | display_emoji: "✨", 140 | }, 141 | SelectMenuSpec { 142 | value: "SelfHosted", 143 | description: "Do you selfhost Gitpod? Then you need this!", 144 | label: "Self Hosted Gitpod", 145 | display_emoji: "🏡", 146 | }, 147 | SelectMenuSpec { 148 | value: "OnMobile", 149 | description: "Talk about using Gitpod on mobile devices", 150 | label: "Mobile and tablets", 151 | display_emoji: "📱", 152 | }, 153 | ]); 154 | 155 | let mut poll_entries: Vec = Vec::from([ 156 | SelectMenuSpec { 157 | value: "Found: FromFriend", 158 | label: "Friend or colleague", 159 | description: "A friend or colleague of mine introduced Gitpod to me", 160 | display_emoji: "🫂", 161 | }, 162 | SelectMenuSpec { 163 | value: "Found: FromGoogle", 164 | label: "Google", 165 | description: "I found Gitpod from a Google search", 166 | display_emoji: "🔎", 167 | }, 168 | SelectMenuSpec { 169 | value: "Found: FromYouTube", 170 | label: "YouTube", 171 | description: "Saw Gitpod on a Youtube Video", 172 | display_emoji: "📺", 173 | }, 174 | SelectMenuSpec { 175 | value: "Found: FromTwitter", 176 | label: "Twitter", 177 | description: "Saw people talking about Gitpod on a Tweet", 178 | display_emoji: "🐦", 179 | }, 180 | SelectMenuSpec { 181 | value: "Found: FromGitRepo", 182 | label: "Git Repository", 183 | description: "Found Gitpod on a Git repository", 184 | display_emoji: "✨", 185 | }, 186 | ]); 187 | 188 | // Add more Roles related with Programming to additional_roles array 189 | for prog_role in [ 190 | "Bash", "C", "CPP", "CSharp", "Docker", "Go", "Haskell", "Java", "Js", "Kotlin", "Lua", 191 | "Nim", "Nix", "Node", "Perl", "Php", "Python", "Ruby", "Rust", 192 | ] 193 | .iter() 194 | { 195 | additional_roles.push(SelectMenuSpec { 196 | label: prog_role, 197 | description: "Discussions", 198 | display_emoji: "📜", 199 | value: prog_role, 200 | }); 201 | } 202 | 203 | // User inputs 204 | let mut role_choices: Vec = Vec::new(); 205 | let mut join_reason = String::new(); 206 | 207 | mci.create_interaction_response(&ctx.http, |r| { 208 | r.kind(InteractionResponseType::ChannelMessageWithSource); 209 | r.interaction_response_data(|d| { 210 | d.content("**[1/5]:** Which additional channels would you like to have access to?"); 211 | d.components(|c| { 212 | c.create_action_row(|a| { 213 | a.create_select_menu(|s| { 214 | s.placeholder("Select channels (Optional)"); 215 | s.options(|o| { 216 | for spec in &additional_roles { 217 | o.create_option(|opt| { 218 | opt.label(spec.label); 219 | opt.description(spec.description); 220 | opt.emoji(ReactionType::Unicode( 221 | spec.display_emoji.to_string(), 222 | )); 223 | opt.value(spec.value) 224 | }); 225 | } 226 | o.create_option(|opt| { 227 | opt.label("[Skip] I don't want any!") 228 | .description("Nopes, I ain't need more.") 229 | .emoji(ReactionType::Unicode("⏭".to_string())) 230 | .value("none"); 231 | opt 232 | }); 233 | o 234 | }); 235 | s.custom_id("channel_choice").max_values(24) 236 | }); 237 | a 238 | }); 239 | c 240 | }); 241 | d.custom_id("additional_roles") 242 | .flags(MessageFlags::EPHEMERAL) 243 | }); 244 | r 245 | }) 246 | .await?; 247 | 248 | let mut interactions = mci 249 | .get_interaction_response(&ctx.http) 250 | .await? 251 | .await_component_interactions(ctx) 252 | .timeout(Duration::from_secs(60 * 5)) 253 | .build(); 254 | 255 | while let Some(interaction) = interactions.next().await { 256 | match interaction.data.custom_id.as_str() { 257 | "channel_choice" => { 258 | interaction.create_interaction_response(&ctx.http, |r| { 259 | r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(|d|{ 260 | d.content("**[2/5]:** Would you like to get notified for announcements and community events?"); 261 | d.components(|c| { 262 | c.create_action_row(|a| { 263 | a.create_button(|b|{ 264 | b.label("Yes!").custom_id("subscribed").style(ButtonStyle::Success) 265 | }); 266 | a.create_button(|b|{ 267 | b.label("No, thank you!").custom_id("not_subscribed").style(ButtonStyle::Danger) 268 | }); 269 | a 270 | }) 271 | }); 272 | d 273 | }) 274 | }).await?; 275 | 276 | // Save the choices of last interaction 277 | interaction 278 | .data 279 | .values 280 | .iter() 281 | .for_each(|x| role_choices.push(x.to_string())); 282 | } 283 | 284 | "subscribed" | "not_subscribed" => { 285 | interaction.create_interaction_response(&ctx.http, |r| { 286 | r.kind(InteractionResponseType::UpdateMessage).interaction_response_data(|d| { 287 | d.content("**[3/5]:** Why did you join our community?\nI will point you to the correct channels with this info.").components(|c| { 288 | c.create_action_row(|a| { 289 | a.create_button(|b|{ 290 | b.label("To hangout with others"); 291 | b.style(ButtonStyle::Secondary); 292 | b.emoji(ReactionType::Unicode("🏄".to_string())); 293 | b.custom_id("hangout") 294 | }); 295 | a.create_button(|b|{ 296 | b.label("To get help with Gitpod.io"); 297 | b.style(ButtonStyle::Secondary); 298 | b.emoji(ReactionType::Unicode("✌️".to_string())); 299 | b.custom_id("gitpodio_help") 300 | }); 301 | a.create_button(|b|{ 302 | b.label("To get help with my selfhosted installation"); 303 | b.style(ButtonStyle::Secondary); 304 | b.emoji(ReactionType::Unicode("🏡".to_string())); 305 | b.custom_id("selfhosted_help") 306 | }); 307 | a 308 | }) 309 | }) 310 | }) 311 | }).await?; 312 | 313 | // Save the choices of last interaction 314 | let subscribed_role = SelectMenuSpec { 315 | label: "Subscribed", 316 | description: "Subscribed to pings", 317 | display_emoji: "", 318 | value: "Subscriber", 319 | }; 320 | if interaction.data.custom_id == "subscribed" { 321 | role_choices.push(subscribed_role.value.to_string()); 322 | } 323 | additional_roles.push(subscribed_role); 324 | } 325 | 326 | "hangout" | "gitpodio_help" | "selfhosted_help" => { 327 | interaction 328 | .create_interaction_response(&ctx.http, |r| { 329 | r.kind(InteractionResponseType::Modal) 330 | .interaction_response_data(|d| { 331 | d.custom_id("company_name_submitted") 332 | .title("[4/5] Share your company name") 333 | .components(|c| { 334 | c.create_action_row(|a| { 335 | a.create_input_text(|t| { 336 | t.custom_id("company_submitted") 337 | .label("Company (optional)") 338 | .placeholder("Type in your company name") 339 | .max_length(50) 340 | .min_length(2) 341 | .required(false) 342 | .style(component::InputTextStyle::Short) 343 | }) 344 | }) 345 | }) 346 | }) 347 | }) 348 | .await?; 349 | 350 | // Save join reason 351 | join_reason.push_str(interaction.data.custom_id.as_str()); 352 | 353 | // Fetch member 354 | let mut member = mci.member.clone().context("Can't fetch member")?; 355 | let member_role = get_role(mci, ctx, "Member").await?; 356 | let never_introduced = { 357 | let mut status = true; 358 | if let Some(roles) = member.roles(&ctx.cache) { 359 | let gitpodder_role = get_role(mci, ctx, "Gitpodders").await?; 360 | status = !roles 361 | .into_iter() 362 | .any(|x| x == member_role || x == gitpodder_role); 363 | } 364 | if status { 365 | let mut count = 0; 366 | if let Ok(intro_msgs) = &ctx 367 | .http 368 | .get_messages(*introduction_channel.as_u64(), "") 369 | .await 370 | { 371 | intro_msgs.iter().for_each(|x| { 372 | if x.author == interaction.user { 373 | count += 1; 374 | } 375 | }); 376 | } 377 | 378 | status = count < 1; 379 | } 380 | status 381 | }; 382 | 383 | let followup = interaction 384 | .create_followup_message(&ctx.http, |d| { 385 | d.content("**[5/5]:** How did you find Gitpod?"); 386 | d.components(|c| { 387 | c.create_action_row(|a| { 388 | a.create_select_menu(|s| { 389 | s.placeholder("[Poll]: Select sources (Optional)"); 390 | s.options(|o| { 391 | for spec in &poll_entries { 392 | o.create_option(|opt| { 393 | opt.label(spec.label); 394 | opt.description(spec.description); 395 | opt.emoji(ReactionType::Unicode( 396 | spec.display_emoji.to_string(), 397 | )); 398 | opt.value(spec.value); 399 | opt 400 | }); 401 | } 402 | o.create_option(|opt| { 403 | opt.label("[Skip] Prefer not to share") 404 | .value("none") 405 | .emoji(ReactionType::Unicode("⏭".to_string())); 406 | opt 407 | }); 408 | o 409 | }); 410 | s.custom_id("found_gitpod_from").max_values(5) 411 | }); 412 | a 413 | }); 414 | c 415 | }); 416 | d.flags(MessageFlags::EPHEMERAL) 417 | }) 418 | .await?; 419 | 420 | let temp_role = get_role(mci, ctx, "Temp").await?; 421 | let followup_results = 422 | match followup 423 | .await_component_interaction(ctx) 424 | .timeout(Duration::from_secs(60 * 5)) 425 | .await 426 | { 427 | Some(ci) => { 428 | member.add_role(&ctx.http, temp_role.id).await?; 429 | let final_msg = 430 | { 431 | if never_introduced { 432 | MessageBuilder::new() 433 | .push_line(format!( 434 | "Thank you {}! To unlock the server, drop by {} :wave:", 435 | interaction.user.mention(), 436 | introduction_channel.mention() 437 | )) 438 | .push_line("\nWe’d love to get to know you better and hear about:") 439 | .push_quote_line("🔧 what you’re working on!") 440 | .push_quote_line("🛑 what blocks you most in your daily dev workflow") 441 | .push_quote_line("🌈 your favourite Gitpod feature") 442 | .push_quote_line("✨ your favourite emoji").build() 443 | } else { 444 | "Awesome, your server profile will be updated now!" 445 | .to_owned() 446 | } 447 | }; 448 | ci.create_interaction_response(&ctx.http, |r| { 449 | r.kind(InteractionResponseType::UpdateMessage) 450 | .interaction_response_data(|d| { 451 | d.content(final_msg).components(|c| c) 452 | }) 453 | }) 454 | .await?; 455 | ci 456 | } 457 | None => bail!("Did not interact in time"), 458 | }; 459 | 460 | // if let Some(interaction) = interaction 461 | // .get_interaction_response(&ctx.http) 462 | // .await 463 | // ? 464 | // .await_component_interaction(&ctx) 465 | // .timeout(Duration::from_secs(60 * 5)) 466 | // .await 467 | // { 468 | 469 | if never_introduced { 470 | // Wait for the submittion on INTRODUCTION_CHANNEL 471 | if let Some(msg) = mci 472 | .user 473 | .await_reply(ctx) 474 | .timeout(Duration::from_secs(60 * 30)) 475 | .await 476 | { 477 | // Watch intro channel 478 | if &msg.channel_id == introduction_channel { 479 | // let mut count = 0; 480 | // intro_msgs.iter().for_each(|x| { 481 | // if x.author == msg.author { 482 | // count += 1; 483 | // } 484 | // }); 485 | 486 | // if count <= 1 { 487 | let thread = msg 488 | .channel_id 489 | .create_public_thread(&ctx.http, &msg.id, |t| { 490 | t.auto_archive_duration(1440) 491 | .name(format!("Welcome {}!", msg.author.name)) 492 | }) 493 | .await?; 494 | 495 | if words_count::count(&msg.content).words > 4 { 496 | for unicode in ["👋", "🔥"] { 497 | msg.react( 498 | &ctx.http, 499 | ReactionType::Unicode(unicode.to_string()), 500 | ) 501 | .await?; 502 | } 503 | } else { 504 | msg.delete(&ctx.http).await?; 505 | } 506 | 507 | let mut prepared_msg = MessageBuilder::new(); 508 | prepared_msg.push_line(format!( 509 | "Welcome to the Gitpod community {} 🙌\n", 510 | &msg.author.mention() 511 | )); 512 | match join_reason.as_str() { 513 | "gitpodio_help" => { 514 | prepared_msg.push_line( 515 | format!("**You mentioned that** you need help with Gitpod.io, please ask in {}\n", 516 | &primary_questions_channel.mention()) 517 | ); 518 | } 519 | "selfhosted_help" => { 520 | let selfhosted_role = get_role(mci, ctx, "SelfHosted").await?; 521 | member.add_role(&ctx.http, selfhosted_role.id).await?; 522 | prepared_msg.push_line( 523 | format!("**You mentioned that** you need help with selfhosted, please ask in {}\n", 524 | &secondary_questions_channel.mention()) 525 | ); 526 | } 527 | _ => {} 528 | } 529 | prepared_msg.push_bold_line("Here are some channels that you should check out:") 530 | .push_quote_line(format!("• {} - for tech, programming and anything related 🖥", &general_channel.mention())) 531 | .push_quote_line(format!("• {} - for any random discussions ☕️", &off_topic_channel.mention())) 532 | .push_quote_line(format!("• {} - have a question about Gitpod? this is the place to ask! ❓\n", &primary_questions_channel.mention())) 533 | .push_line("…And there’s more! Take your time to explore :)\n") 534 | .push_bold_line("Feel free to check out the following pages to learn more about Gitpod:") 535 | .push_quote_line("• https://www.gitpod.io/community") 536 | .push_quote_line("• https://www.gitpod.io/about"); 537 | let mut thread_msg = thread 538 | .send_message(&ctx.http, |t| t.content(prepared_msg)) 539 | .await?; 540 | thread_msg.suppress_embeds(&ctx.http).await?; 541 | // } else { 542 | // let warn_msg = msg 543 | // .reply_mention( 544 | // &ctx.http, 545 | // "Please reply in threads above instead of here", 546 | // ) 547 | // .await 548 | // ?; 549 | // sleep(Duration::from_secs(10)).await; 550 | // warn_msg.delete(&ctx.http).await?; 551 | // msg.delete(&ctx.http).await.ok(); 552 | // } 553 | } 554 | // } 555 | } 556 | } 557 | 558 | // save the found from data 559 | followup_results 560 | .data 561 | .values 562 | .iter() 563 | .for_each(|x| role_choices.push(x.to_string())); 564 | 565 | // Remove old roles 566 | if let Some(roles) = member.roles(&ctx.cache) { 567 | // Remove all assignable roles first 568 | let mut all_assignable_roles: Vec = Vec::new(); 569 | all_assignable_roles.append(&mut additional_roles); 570 | all_assignable_roles.append(&mut poll_entries); 571 | let mut removeable_roles: Vec = Vec::new(); 572 | 573 | for role in roles { 574 | if all_assignable_roles.iter().any(|x| x.value == role.name) { 575 | removeable_roles.push(role.id); 576 | } 577 | } 578 | if !removeable_roles.is_empty() { 579 | member.remove_roles(&ctx.http, &removeable_roles).await?; 580 | } 581 | } 582 | 583 | assign_roles( 584 | mci, 585 | ctx, 586 | role_choices, 587 | &mut member, 588 | &temp_role, 589 | &member_role, 590 | ) 591 | .await?; 592 | 593 | break; 594 | } 595 | _ => {} 596 | } 597 | } 598 | 599 | Ok(()) 600 | } 601 | -------------------------------------------------------------------------------- /src/event/interaction_create/mod.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use serde_json::json; 3 | use serenity::model::application::component::ActionRowComponent; 4 | use serenity::{client::Context, model::application::interaction::Interaction}; 5 | 6 | // Internals 7 | mod close_thread; 8 | mod getting_started; 9 | mod question_thread_suggestions; 10 | mod slash_commands; 11 | 12 | use serenity::model::application::interaction::modal::ModalSubmitInteraction; 13 | 14 | use crate::BOT_CONFIG; 15 | async fn company_name_submitted_response( 16 | mci: &ModalSubmitInteraction, 17 | ctx: &Context, 18 | ) -> Result<()> { 19 | mci.create_interaction_response(&ctx.http, |r| { 20 | r.kind(serenity::model::prelude::InteractionResponseType::UpdateMessage) 21 | .interaction_response_data(|d| { 22 | d.content("**[4/4]**: You have personalized the server, congrats!") 23 | .components(|c| c) 24 | }) 25 | }) 26 | .await?; 27 | if let Some(component) = &mci.data.components.get(0) 28 | && let Some(input_field) = component.components.get(0) 29 | && let ActionRowComponent::InputText(it) = input_field 30 | && !it.value.is_empty() 31 | && let Some(config) = BOT_CONFIG.get() && let Some(misc) = &config.misc 32 | { 33 | reqwest::Client::new() 34 | .post(&misc.company_share_endpoint) 35 | .json(&json!( 36 | { 37 | "userid": mci.user.id, 38 | "username": mci.user.name, 39 | "company": it.value 40 | } 41 | )).send().await?; 42 | } 43 | Ok(()) 44 | } 45 | 46 | pub async fn responder(ctx: &Context, interaction: Interaction) -> Result<()> { 47 | match interaction { 48 | Interaction::MessageComponent(mci) => match mci.data.custom_id.as_str() { 49 | "gitpod_close_issue" => close_thread::responder(&mci, ctx).await?, 50 | "getting_started_letsgo" => getting_started::responder(&mci, ctx).await?, 51 | _ => question_thread_suggestions::responder(&mci, ctx).await?, 52 | }, 53 | Interaction::ApplicationCommand(mci) => match mci.data.name.as_str() { 54 | "close" => close_thread::responder(&mci, ctx).await?, 55 | "nothing_to_see_here" => { 56 | slash_commands::nothing_to_see_here::responder(mci, ctx).await? 57 | } 58 | "create-pr" => slash_commands::create_pr::responder(&mci, ctx).await?, 59 | 60 | _ => {} 61 | }, 62 | Interaction::ModalSubmit(mci) => if mci.data.custom_id.as_str() == "company_name_submitted" { 63 | company_name_submitted_response(&mci, ctx).await? 64 | } 65 | 66 | _ => {} 67 | } 68 | 69 | Ok(()) 70 | } 71 | -------------------------------------------------------------------------------- /src/event/interaction_create/question_thread_suggestions.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context as _, Result}; 2 | use serenity::prelude::*; 3 | 4 | use serenity::{ 5 | client::Context, 6 | model::{ 7 | application::{ 8 | component::ButtonStyle, 9 | interaction::message_component::MessageComponentInteraction, 10 | interaction::{InteractionResponseType, MessageFlags}, 11 | }, 12 | channel::ReactionType, 13 | prelude::component::Button, 14 | }, 15 | }; 16 | 17 | pub async fn responder(mci: &MessageComponentInteraction, ctx: &Context) -> Result<()> { 18 | if mci.data.custom_id.starts_with("http") { 19 | let button_label = &mci 20 | .message 21 | .components 22 | .iter() 23 | .find_map(|a| { 24 | a.components.iter().find_map(|x| { 25 | let button: Button = 26 | serde_json::from_value(serde_json::to_value(x).unwrap()).unwrap(); 27 | if button.custom_id? == mci.data.custom_id { 28 | Some(button.label?) 29 | } else { 30 | None 31 | } 32 | }) 33 | }) 34 | .context("Failed to get button label")?; 35 | 36 | mci.create_interaction_response(&ctx.http, |r| { 37 | r.kind(InteractionResponseType::ChannelMessageWithSource) 38 | .interaction_response_data(|d| { 39 | d.flags(MessageFlags::EPHEMERAL); 40 | d.content(format!("{}: {button_label}", &mci.user.mention())) 41 | .components(|c| { 42 | c.create_action_row(|a| { 43 | a.create_button(|b| { 44 | b.label("Open link") 45 | .url(&mci.data.custom_id) 46 | .style(ButtonStyle::Link) 47 | }) 48 | }) 49 | }) 50 | // .flags(MessageFlags::EPHEMERAL) 51 | }) 52 | }) 53 | .await 54 | .unwrap(); 55 | 56 | mci.message 57 | .react(&ctx.http, ReactionType::Unicode("🔎".to_string())) 58 | .await 59 | .unwrap(); 60 | } 61 | 62 | Ok(()) 63 | } 64 | -------------------------------------------------------------------------------- /src/event/interaction_create/questions.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gitpod-io/optimus/810727a16a8dd7da9ff5b782586a2d5ed2cdf229/src/event/interaction_create/questions.rs -------------------------------------------------------------------------------- /src/event/interaction_create/slash_commands/create_pr.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Context as _, Result}; 2 | use base64::{engine::general_purpose, Engine as _}; 3 | use openai::{ 4 | chat::{ChatCompletion, ChatCompletionMessage, ChatCompletionMessageRole}, 5 | set_key, 6 | }; 7 | use regex::Regex; 8 | use reqwest::{header, header::HeaderValue, Client, StatusCode}; 9 | use serde::Deserialize; 10 | use serde_json::json; 11 | use serenity::{ 12 | client::Context, 13 | futures::StreamExt, 14 | model::application::interaction::application_command::ApplicationCommandInteraction, 15 | model::{ 16 | application::interaction::InteractionResponseType, 17 | prelude::application_command::CommandDataOptionValue, 18 | }, 19 | }; 20 | use std::collections::HashMap; 21 | 22 | use crate::BOT_CONFIG; 23 | 24 | const SIGNATURE: &str = ""; 25 | 26 | #[derive(Default)] 27 | struct GitHubAPI { 28 | origin_api_root: String, 29 | upstream_api_root: String, 30 | client: Client, 31 | token: String, 32 | user_agent: String, 33 | origin_work_branch_name: String, 34 | upstream_main_branch_name: String, 35 | upstream_user_name: String, 36 | origin_user_name: String, 37 | } 38 | 39 | #[derive(Deserialize)] 40 | struct RepoBranch { 41 | object: RepoBranchObject, 42 | } 43 | 44 | #[derive(Deserialize)] 45 | struct RepoBranchObject { 46 | sha: String, 47 | } 48 | 49 | #[derive(Deserialize)] 50 | struct RepoFile { 51 | sha: String, 52 | path: String, 53 | content: String, 54 | } 55 | 56 | #[derive(Deserialize)] 57 | struct GitHubPullReqObj { 58 | html_url: String, 59 | } 60 | 61 | impl GitHubAPI { 62 | fn from(self) -> Self { 63 | let client = Client::builder() 64 | .default_headers( 65 | [ 66 | ( 67 | header::USER_AGENT, 68 | self.user_agent.parse().expect("Can't parse user agent"), 69 | ), 70 | ( 71 | header::AUTHORIZATION, 72 | format!("Bearer {}", self.token) 73 | .parse() 74 | .expect("Can't parse token"), 75 | ), 76 | ( 77 | header::ACCEPT, 78 | HeaderValue::from_static("application/vnd.github+json"), 79 | ), 80 | ] 81 | .into_iter() 82 | .collect(), 83 | ) 84 | .build() 85 | .expect("Can't build http client"); 86 | 87 | Self { 88 | origin_api_root: self.origin_api_root, 89 | upstream_api_root: self.upstream_api_root, 90 | token: self.token, 91 | user_agent: self.user_agent, 92 | client, 93 | origin_work_branch_name: self.origin_work_branch_name, 94 | upstream_main_branch_name: self.upstream_main_branch_name, 95 | upstream_user_name: self.upstream_user_name, 96 | origin_user_name: self.origin_user_name, 97 | } 98 | } 99 | 100 | async fn sync_fork_from_upstream( 101 | &self, 102 | // owner: &str, 103 | // repo: &str, 104 | branch: &str, 105 | ) -> Result { 106 | self.client 107 | .post(format!("{}/merge-upstream", &self.origin_api_root)) 108 | .json(&HashMap::from([("branch", branch)])) 109 | .send() 110 | .await 111 | } 112 | 113 | async fn create_or_delete_branch( 114 | &self, 115 | main_branch: &str, 116 | /* owner: &str, repo: &str, */ branch: &str, 117 | action: &str, 118 | ) -> Result<()> { 119 | let get_branch = self 120 | .client 121 | .get(format!("{}/branches/{branch}", &self.origin_api_root)) 122 | .send() 123 | .await?; 124 | 125 | // Only create if the branch doesn't exist 126 | match action { 127 | "create" => { 128 | if get_branch.status().eq(&StatusCode::NOT_FOUND) { 129 | let main_branch_sha = self 130 | .client 131 | .get(format!( 132 | "{}/git/refs/heads/{main_branch}", 133 | &self.origin_api_root 134 | )) 135 | .send() 136 | .await? 137 | .json::() 138 | .await?; 139 | 140 | let _ = self 141 | .client 142 | .post(format!("{}/git/refs", &self.origin_api_root)) 143 | .json(&HashMap::from([ 144 | ("ref", "refs/heads/".to_owned() + branch), 145 | ("sha", main_branch_sha.object.sha), 146 | ])) 147 | .send() 148 | .await?; 149 | 150 | // if !create_branch.status().eq(&StatusCode::OK) { 151 | // bail!("Can't create branch"); 152 | // } 153 | } 154 | } 155 | "delete" => { 156 | if get_branch.status().eq(&StatusCode::OK) { 157 | let _ = self 158 | .client 159 | .delete(format!("{}/git/refs/heads/{branch}", &self.origin_api_root)) 160 | .send() 161 | .await?; 162 | } 163 | } 164 | 165 | _ => {} 166 | } 167 | 168 | Ok(()) 169 | } 170 | 171 | async fn get_file(&self, path: &str, branch: &str) -> Result { 172 | let req = self 173 | .client 174 | .get(format!("{}/contents/{path}", &self.origin_api_root)) 175 | .query(&[("ref", branch)]) 176 | .send() 177 | .await?; 178 | 179 | let req = req.json::().await?; 180 | Ok(req) 181 | } 182 | 183 | async fn commit( 184 | &self, 185 | path: &str, 186 | message: &str, 187 | committer_name: &str, 188 | committer_email: &str, 189 | content: &str, 190 | original_sha: &str, 191 | branch: &str, 192 | ) -> Result { 193 | let req = self 194 | .client 195 | .put(format!("{}/contents/{path}", &self.origin_api_root)) 196 | .json(&json!({ 197 | "message": message, 198 | "committer": { 199 | "name": committer_name, 200 | "email": committer_email, 201 | }, 202 | "content": content, 203 | "sha": original_sha, 204 | "branch": branch, 205 | })) 206 | .send() 207 | .await?; 208 | 209 | Ok(req) 210 | } 211 | 212 | async fn get_origin_pr_on_upstream(&self) -> Result { 213 | if let Ok(value) = self 214 | .client 215 | .get(format!("{}/pulls", &self.upstream_api_root)) 216 | .query(&[ 217 | ("state", "open"), 218 | ( 219 | "head", 220 | format!( 221 | "{}:{}", 222 | &self.origin_user_name, &self.origin_work_branch_name 223 | ) 224 | .as_str(), 225 | ), 226 | ]) 227 | .send() 228 | .await? 229 | .json::>() 230 | .await 231 | { 232 | let first = value.first(); 233 | if first.is_some() { 234 | return Ok(String::from(&first.context("Cant get first")?.html_url)); 235 | } 236 | } 237 | 238 | bail!("Couldn't fetch open PRs on upstream"); 239 | } 240 | 241 | async fn pull_request( 242 | &self, 243 | title: &str, 244 | body: &str, 245 | head: &str, 246 | base: &str, 247 | ) -> Result { 248 | let req = self 249 | .client 250 | .post(format!("{}/pulls", &self.upstream_api_root)) 251 | .json(&json!({ 252 | "title": title, 253 | "body": body, 254 | "base": base, 255 | "head": head, 256 | "maintainer_can_modify": true, 257 | })) 258 | .send() 259 | .await?; 260 | 261 | Ok(req.json::().await?.html_url) 262 | } 263 | } 264 | 265 | pub async fn responder(mci: &ApplicationCommandInteraction, ctx: &Context) -> Result<()> { 266 | let channel_id = mci.channel_id; 267 | let thread_node = channel_id 268 | .to_channel(&ctx.http) 269 | .await? 270 | .guild() 271 | .context("Failed to convert into Guild")?; 272 | let thread_id = &thread_node.id; 273 | let guild_id = &mci.guild_id.context("Failed to get guild ID")?; 274 | let options = &mci.data.options; 275 | let config = BOT_CONFIG.get().context("Failed to get BotConfig")?; 276 | 277 | let link = &options 278 | .get(0) 279 | .context("Failed to get link")? 280 | .value 281 | .as_ref() 282 | .context("Error getting value")? 283 | .to_string(); 284 | let link = link.trim_start_matches('"').trim_end_matches('"'); 285 | 286 | let title = &options 287 | .iter() 288 | .find_map(|op| { 289 | if op.name == "title" 290 | && let Some(res) = &op.resolved 291 | && let CommandDataOptionValue::String(value) = res { 292 | Some(value) 293 | } else { 294 | None 295 | } 296 | }) 297 | .unwrap_or(&thread_node.name); 298 | let title = title.trim_start_matches('"').trim_end_matches('"'); 299 | 300 | let gpt4 = &options 301 | .iter() 302 | .find_map(|op| { 303 | if op.name == "gpt4" 304 | && let Some(res) = &op.resolved 305 | && let CommandDataOptionValue::Boolean(res) = res { 306 | Some(res) 307 | } else { 308 | None 309 | } 310 | }) 311 | .unwrap_or(&false); 312 | 313 | mci.create_interaction_response(&ctx.http, |r| { 314 | r.kind(InteractionResponseType::DeferredChannelMessageWithSource) 315 | }) 316 | .await?; 317 | 318 | let mut sanitized_messages: Vec = Vec::new(); 319 | let mut messages_iter = mci.channel_id.messages_iter(&ctx.http).boxed(); 320 | 321 | while let Some(message_result) = messages_iter.next().await { 322 | if let Ok(message) = message_result { 323 | // Skip if bot 324 | if message.author.bot { 325 | continue; 326 | } 327 | 328 | let attachments = &message 329 | .attachments 330 | .into_iter() 331 | .map(|a| format!("{}\n", a.url)) 332 | .collect::(); 333 | 334 | let content = Regex::new(r#"<(?:a:\w+:)?(?:@|(?:@!)|(?:@&)|#)\d+>"#)? 335 | .replace_all(message.content.as_str(), "") 336 | .to_string(); 337 | let content = Regex::new(r#"```"#)?.replace(content.as_str(), "\n```"); 338 | 339 | sanitized_messages.push(format!( 340 | "\n**{}#{}**: {}\n{attachments}", 341 | message.author.name, message.author.discriminator, content 342 | )); 343 | } 344 | } 345 | 346 | sanitized_messages.push(format!( 347 | "### [{}](https://discord.com/channels/{guild_id}/{thread_id})\n{}\n", 348 | title, SIGNATURE 349 | )); 350 | sanitized_messages.reverse(); 351 | 352 | // Use GPT to summarize the messages if available 353 | let sanitized_messages = { 354 | let conversation = sanitized_messages.clone().into_iter().collect::(); 355 | let mut ret = conversation.clone(); 356 | 357 | if let Some(openai) = &config.openai { 358 | let prompt = format!( 359 | "{}\n{}\n{}\n{}\n{}\n\n```\n{}\n```", 360 | "Below is a discord conversation for documenting as a FAQ on a (markdown) web page, can you convert it to a concise FAQ for me?", 361 | "Rules:", 362 | "1. Shouldn't read like a conversation", 363 | "2. Should persist the heading discord link", 364 | "3. Shouldn't use inline backticks but rather code blocks for representing bash commands or code", 365 | conversation 366 | ); 367 | set_key(openai.api_key.clone()); 368 | 369 | // TODO: Figure out a good system message later. 370 | // TODO: Make the LLM figure out target page URL also. 371 | let messages = vec![ChatCompletionMessage { 372 | role: ChatCompletionMessageRole::User, 373 | content: prompt, 374 | name: None, 375 | }]; 376 | 377 | let model = if **gpt4 { "gpt-4" } else { "gpt-3.5-turbo" }; 378 | 379 | if let Ok(Ok(http_req)) = &ChatCompletion::builder(model, messages).create().await 380 | && let Some(choice) = http_req.choices.first() 381 | { 382 | ret = choice.message.content.clone(); 383 | } 384 | } 385 | 386 | ret 387 | }; 388 | 389 | let github = config 390 | .github 391 | .as_ref() 392 | .context("Failed to get GitHub credentials")? 393 | .clone(); 394 | 395 | let bot_account_username = String::from("gitpod-community"); 396 | let github_client = GitHubAPI::from(GitHubAPI { 397 | origin_api_root: format!("https://api.github.com/repos/{bot_account_username}/website"), 398 | upstream_api_root: "https://api.github.com/repos/gitpod-io/website".to_owned(), 399 | token: github.api_token, 400 | user_agent: github.user_agent, 401 | upstream_main_branch_name: "main".to_owned(), 402 | upstream_user_name: "gitpod-io".to_owned(), 403 | origin_work_branch_name: "discord_staging".to_owned(), 404 | origin_user_name: bot_account_username, 405 | ..Default::default() 406 | }); 407 | 408 | let relative_file_path = Regex::new(r#"^.*/docs/"#)?.replace(link, "gitpod/docs/"); 409 | 410 | // Sync fork 411 | github_client 412 | .sync_fork_from_upstream(github_client.upstream_main_branch_name.as_str()) 413 | .await?; 414 | 415 | if github_client.get_origin_pr_on_upstream().await.is_err() { 416 | // Delete branch if no PR is open in upstream 417 | github_client 418 | .create_or_delete_branch( 419 | github_client.upstream_main_branch_name.as_str(), 420 | github_client.origin_work_branch_name.as_str(), 421 | "delete", 422 | ) 423 | .await 424 | // Ignore any error. 425 | .ok(); 426 | 427 | // Create branch 428 | github_client 429 | .create_or_delete_branch( 430 | github_client.upstream_main_branch_name.as_str(), 431 | github_client.origin_work_branch_name.as_str(), 432 | "create", 433 | ) 434 | .await?; 435 | } 436 | 437 | // Committing the changes 438 | ///////////////////////// 439 | 440 | // Get file object 441 | let file = { 442 | if let Ok(result) = github_client 443 | .get_file( 444 | format!("{relative_file_path}.md").as_str(), 445 | github_client.origin_work_branch_name.as_str(), 446 | ) 447 | .await 448 | { 449 | result 450 | } else if let Ok(result) = github_client 451 | .get_file( 452 | format!("{relative_file_path}/index.md").as_str(), 453 | github_client.origin_work_branch_name.as_str(), 454 | ) 455 | .await 456 | { 457 | result 458 | } else { 459 | mci.edit_original_interaction_response(&ctx.http, |r| { 460 | r.content(format!("Error: {relative_file_path} does not exist, maybe you need to resolve a redirect?")) 461 | }) 462 | .await?; 463 | bail!("{relative_file_path} does not exist"); 464 | } 465 | }; 466 | 467 | // Prepare new file contents 468 | let file_contents_decoded = { 469 | let decoded = general_purpose::STANDARD 470 | .decode(file.content.split_whitespace().collect::())?; 471 | let decoded = String::from_utf8(decoded)?; 472 | 473 | // Append to FAQs 474 | if decoded.contains("FAQs") { 475 | Regex::new("FAQs")? 476 | .replace(decoded.as_str(), format!("FAQs\n\n{sanitized_messages}")) 477 | .to_string() 478 | } else { 479 | format!("{decoded}\n\n## FAQs\n\n{sanitized_messages}") 480 | } 481 | }; 482 | 483 | // Base64 encode 484 | let file_contents_encoded = general_purpose::STANDARD.encode(file_contents_decoded); 485 | 486 | // Commit the new changes 487 | github_client 488 | .commit( 489 | file.path.as_str(), 490 | format!("Update {}", file.path).as_str(), 491 | "Gitpod Community", 492 | "community-bot@gitpod.io", 493 | file_contents_encoded.as_str(), 494 | file.sha.as_str(), 495 | github_client.origin_work_branch_name.as_str(), 496 | ) 497 | .await?; 498 | 499 | // Create PR 500 | let pr_link = { 501 | let pr = github_client.get_origin_pr_on_upstream().await; 502 | 503 | if pr.is_ok() { 504 | pr? 505 | } else { 506 | github_client 507 | .pull_request( 508 | format!("Add FAQ for {relative_file_path}").as_str(), 509 | "Pulling a Discord thread as FAQ", 510 | format!( 511 | "{}:{}", 512 | github_client.origin_user_name, github_client.origin_work_branch_name, 513 | ) 514 | .as_str(), 515 | github_client.upstream_main_branch_name.as_str(), 516 | ) 517 | .await? 518 | } 519 | }; 520 | 521 | mci.edit_original_interaction_response(&ctx.http, |r| { 522 | r.content(format!("PR for this thread conversation: {pr_link}")) 523 | }) 524 | .await?; 525 | 526 | Ok(()) 527 | } 528 | -------------------------------------------------------------------------------- /src/event/interaction_create/slash_commands/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod create_pr; 2 | pub mod nothing_to_see_here; 3 | -------------------------------------------------------------------------------- /src/event/interaction_create/slash_commands/nothing_to_see_here.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context as _, Result}; 2 | use serenity::model::application::interaction::{ 3 | application_command::ApplicationCommandInteraction, MessageFlags, 4 | }; 5 | use serenity::{client::Context, model::application::interaction::InteractionResponseType}; 6 | 7 | pub async fn responder(mci: ApplicationCommandInteraction, ctx: &Context) -> Result<()> { 8 | let input = mci 9 | .data 10 | .options 11 | .get(0) 12 | .context("Expected input")? 13 | .value 14 | .as_ref() 15 | .context("Failed ref for input")?; 16 | 17 | mci.create_interaction_response(&ctx.http, |r| { 18 | r.kind(InteractionResponseType::ChannelMessageWithSource) 19 | .interaction_response_data(|d| { 20 | d.content("Posted message on this channel") 21 | .flags(MessageFlags::EPHEMERAL) 22 | }) 23 | }) 24 | .await?; 25 | 26 | mci.channel_id 27 | .send_message(&ctx.http, |m| { 28 | m.content( 29 | input 30 | .to_string() 31 | .trim_start_matches('"') 32 | .trim_end_matches('"'), 33 | ) 34 | }) 35 | .await?; 36 | 37 | Ok(()) 38 | } 39 | -------------------------------------------------------------------------------- /src/event/interaction_create/slash_commands/pull.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /src/event/interaction_create/utils.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::db::ClientContextExt; 3 | use interactions::*; 4 | 5 | use serenity::{ 6 | futures::StreamExt, 7 | // http::AttachmentType, 8 | model::{ 9 | self, 10 | application::interaction::{message_component::MessageComponentInteraction, MessageFlags}, 11 | guild::Role, 12 | id::RoleId, 13 | prelude::component::Button, 14 | Permissions, 15 | }, 16 | utils::MessageBuilder, 17 | }; 18 | 19 | #[derive(Clone, Copy)] 20 | struct SelectMenuSpec<'a> { 21 | value: &'a str, 22 | label: &'a str, 23 | display_emoji: &'a str, 24 | description: &'a str, 25 | } 26 | 27 | async fn safe_text(_ctx: &Context, _input: &String) -> String { 28 | content_safe( 29 | &_ctx.cache, 30 | _input, 31 | &ContentSafeOptions::default() 32 | .clean_channel(false) 33 | .clean_role(true) 34 | .clean_user(false), 35 | &[], 36 | ) 37 | } 38 | 39 | async fn get_role( 40 | mci: &model::application::interaction::message_component::MessageComponentInteraction, 41 | ctx: &Context, 42 | name: &str, 43 | ) -> Role { 44 | let role = { 45 | if let Some(result) = mci 46 | .guild_id 47 | .unwrap() 48 | .to_guild_cached(&ctx.cache) 49 | .unwrap() 50 | .role_by_name(name) 51 | { 52 | result.clone() 53 | } else { 54 | let r = mci 55 | .guild_id 56 | .unwrap() 57 | .create_role(&ctx.http, |r| { 58 | r.name(&name); 59 | r.mentionable(false); 60 | r.hoist(false); 61 | r 62 | }) 63 | .await 64 | .unwrap(); 65 | r.clone() 66 | } 67 | }; 68 | if role.name != "Member" && role.name != "Gitpodders" && !role.permissions.is_empty() { 69 | role.edit(&ctx.http, |r| r.permissions(Permissions::empty())) 70 | .await 71 | .unwrap(); 72 | } 73 | 74 | role 75 | } 76 | 77 | async fn close_issue(mci: &MessageComponentInteraction, ctx: &Context) { 78 | let thread_node = mci 79 | .channel_id 80 | .to_channel(&ctx.http) 81 | .await 82 | .unwrap() 83 | .guild() 84 | .unwrap(); 85 | 86 | let thread_type = { 87 | if [QUESTIONS_CHANNEL, SELFHOSTED_QUESTIONS_CHANNEL] 88 | .contains(&thread_node.parent_id.unwrap()) 89 | { 90 | "question" 91 | } else { 92 | "thread" 93 | } 94 | }; 95 | 96 | let thread_name = { 97 | if thread_node.name.contains('✅') || thread_type == "thread" { 98 | thread_node.name 99 | } else { 100 | format!("✅ {}", thread_node.name.trim_start_matches("❓ ")) 101 | } 102 | }; 103 | let action_user_mention = mci.member.as_ref().unwrap().mention(); 104 | let response = format!("This {} was closed by {}", thread_type, action_user_mention); 105 | mci.channel_id.say(&ctx.http, &response).await.unwrap(); 106 | mci.create_interaction_response(&ctx.http, |r| { 107 | r.kind(InteractionResponseType::UpdateMessage); 108 | r.interaction_response_data(|d| d) 109 | }) 110 | .await 111 | .unwrap(); 112 | 113 | mci.channel_id 114 | .edit_thread(&ctx.http, |t| t.archived(true).name(thread_name)) 115 | .await 116 | .unwrap(); 117 | } 118 | 119 | async fn assign_roles( 120 | mci: &MessageComponentInteraction, 121 | ctx: &Context, 122 | role_choices: Vec, 123 | member: &mut Member, 124 | temp_role: &Role, 125 | member_role: &Role, 126 | ) { 127 | if role_choices.len() > 1 || !role_choices.iter().any(|x| x == "none") { 128 | // Is bigger than a single choice or doesnt contain none 129 | 130 | let mut role_ids: Vec = Vec::new(); 131 | for role_name in role_choices { 132 | if role_name == "none" { 133 | continue; 134 | } 135 | let role = get_role(mci, ctx, role_name.as_str()).await; 136 | role_ids.push(role.id); 137 | } 138 | member.add_roles(&ctx.http, &role_ids).await.unwrap(); 139 | let db = &ctx.get_db().await; 140 | db.set_user_roles(mci.user.id, role_ids).await.unwrap(); 141 | } 142 | 143 | // Remove the temp role from user 144 | if member.roles.iter().any(|x| x == &temp_role.id) { 145 | member.remove_role(&ctx.http, temp_role.id).await.unwrap(); 146 | } 147 | // Add member role if missing 148 | if !member.roles.iter().any(|x| x == &member_role.id) { 149 | member.add_role(&ctx.http, member_role.id).await.unwrap(); 150 | } 151 | } 152 | 153 | -------------------------------------------------------------------------------- /src/event/message.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub async fn responder(ctx: Context, msg: Message) -> Result<()> { 4 | if !msg.is_own(&ctx.cache) { 5 | // Handle forum channel posts 6 | new_question::responder(&ctx, &msg).await.unwrap(); 7 | 8 | // Log messages 9 | // let dbnode_msgcache = Database::from("msgcache".to_string()).await; 10 | 11 | // let attc = &_msg.attachments; 12 | // let mut _attachments = String::new(); 13 | 14 | // for var in attc.iter() { 15 | // let url = &var.url; 16 | // _attachments.push_str(format!("\n{}", url).as_str()); 17 | // } 18 | 19 | // let v: Value = serde_json::from_str(&_msg.attachments.iter().map(|x| x.proxy_url.as_str())).unwrap(); 20 | // dbnode_msgcache 21 | // .save_msg( 22 | // &_msg.id, 23 | // format!( 24 | // "{}{}\n> ---MSG_TYPE--- {} `||` At: {}", 25 | // &_msg.content, 26 | // &_attachments, 27 | // &_msg.author, 28 | // &_msg.timestamp.format("%H:%M:%S %p") 29 | // ), 30 | // ) 31 | // .await; 32 | } 33 | 34 | Ok(()) 35 | 36 | // 37 | // Auto respond on keywords 38 | // 39 | 40 | // let dbnode_notes = Database::from("notes".to_string()).await; 41 | // let ref_msg = &_msg.referenced_message; 42 | 43 | // let options = MatchOptions { 44 | // case_sensitive: false, 45 | // require_literal_separator: false, 46 | // require_literal_leading_dot: false, 47 | // }; 48 | // if !_msg.author.bot && !_msg.content.contains("dnote ") { 49 | // for entry in glob_with(format!("{}/*", dbnode_notes).as_str(), options).unwrap() { 50 | // match entry { 51 | // Ok(path) => { 52 | // let note = path.file_name().unwrap().to_string_lossy().to_string(); 53 | 54 | // if _msg 55 | // .content 56 | // .to_lowercase() 57 | // .contains(¬e.as_str().to_lowercase()) 58 | // { 59 | // let typing = _ctx 60 | // .http 61 | // .start_typing(u64::try_from(_msg.channel_id).unwrap()) 62 | // .unwrap(); 63 | 64 | // // Use contentsafe options 65 | // let settings = { 66 | // ContentSafeOptions::default() 67 | // .clean_channel(false) 68 | // .clean_role(true) 69 | // .clean_user(false) 70 | // .clean_everyone(true) 71 | // .clean_here(true) 72 | // }; 73 | 74 | // let content = content_safe( 75 | // &_ctx.cache, 76 | // Note::from(¬e).await.get_contents().await, 77 | // &settings, 78 | // ) 79 | // .await; 80 | // if ref_msg.is_some() { 81 | // ref_msg 82 | // .as_ref() 83 | // .map(|x| x.reply_ping(&_ctx.http, &content)) 84 | // .unwrap() 85 | // .await 86 | // .unwrap() 87 | // .react(&_ctx.http, '❎') 88 | // .await 89 | // .unwrap(); 90 | // } else { 91 | // _msg.reply(&_ctx.http, &content) 92 | // .await 93 | // .unwrap() 94 | // .react(&_ctx.http, '❎') 95 | // .await 96 | // .unwrap(); 97 | // } 98 | // typing.stop(); 99 | // } 100 | // } 101 | // Err(e) => println!("{:?}", e), 102 | // } 103 | // } 104 | // } 105 | 106 | // let user_date = &_msg.author.created_at().naive_utc().date(); 107 | // let user_time = &_msg.author.created_at().naive_utc().time(); 108 | // let _how_old = ""; 109 | // let _system_channel_id = _ctx 110 | // .cache 111 | // .guild(&_msg.guild_id.unwrap()) 112 | // .await 113 | // .map(|x| x.system_channel_id) 114 | // .unwrap() 115 | // .unwrap(); 116 | 117 | // _ctx.http 118 | // .send_message( 119 | // u64::try_from(_system_channel_id).unwrap(), 120 | // &json!({ 121 | // "content": 122 | // format!( 123 | // "> :arrow_forward: {}'s account Date: **{}**; Time: **{}**", 124 | // &_msg.author, &user_date, &user_time 125 | // ) 126 | // }), 127 | // ) 128 | // .await 129 | // .unwrap(); 130 | } 131 | -------------------------------------------------------------------------------- /src/event/message_delete.rs: -------------------------------------------------------------------------------- 1 | use crate::db::ClientContextExt; 2 | 3 | use super::*; 4 | 5 | pub async fn responder( 6 | _ctx: Context, 7 | _channel_id: ChannelId, 8 | _deleted_message_id: MessageId, 9 | _guild_id: Option, 10 | ) { 11 | let db = &_ctx.get_db().await; 12 | if let Ok(qc) = db.get_question_channels().await { 13 | if qc.iter().any(|x| x.id == _channel_id) { 14 | return; 15 | } 16 | } 17 | 18 | let dbnode = Database::from("msgcache".to_string()).await; 19 | if !dbnode.msg_exists(&_deleted_message_id).await { 20 | return; 21 | } 22 | 23 | let deleted_message = dbnode.fetch_msg(_deleted_message_id).await; 24 | 25 | // let last_msg_id = _new 26 | // .unwrap() 27 | // .channel(&_ctx.cache) 28 | // .await 29 | // .unwrap() 30 | // .guild() 31 | // .unwrap() 32 | // .last_message_id 33 | // .unwrap(); 34 | 35 | let qq = &_ctx 36 | .http 37 | .get_messages(u64::try_from(_channel_id).unwrap(), "") 38 | .await 39 | .unwrap(); 40 | 41 | let gg = &_ctx.cache.guild(_guild_id.unwrap()).unwrap(); 42 | 43 | let nqn_exists = &gg.member(&_ctx.http, 559426966151757824).await; 44 | 45 | let botis = &qq.first().as_ref().map(|x| x.author.id).unwrap(); 46 | 47 | let is_valid_member = gg.member(&_ctx.http, botis).await; 48 | 49 | let re0 = Regex::new(r"(<:|]").unwrap(); 52 | let re3 = Regex::new("\\n.* ---MSG_TYPE---.*").unwrap(); 53 | 54 | let mut parsed_last_msg = re 55 | .replace_all( 56 | &qq.first() 57 | .as_ref() 58 | .map(|x| String::from(&x.content)) 59 | .unwrap(), 60 | "", 61 | ) 62 | .to_string(); 63 | 64 | // for _ in 1..10 { 65 | // parsed_last_msg = re.replace_all(&parsed_last_msg, "").to_string(); 66 | // } 67 | 68 | parsed_last_msg = re0.replace_all(&parsed_last_msg, "").to_string(); 69 | parsed_last_msg = re2.replace_all(&parsed_last_msg, "").to_string(); 70 | 71 | let mut parsed_deleted_msg = re0.replace_all(deleted_message.as_str(), "").to_string(); 72 | parsed_deleted_msg = re.replace_all(&parsed_deleted_msg, "").to_string(); 73 | parsed_deleted_msg = re2.replace_all(&parsed_deleted_msg, "").to_string(); 74 | parsed_deleted_msg = re3.replace_all(&parsed_deleted_msg, "").to_string(); 75 | 76 | let msg_is_nqnbot = { 77 | if nqn_exists.is_err() { 78 | false 79 | } else if is_valid_member.is_err() { 80 | parsed_last_msg.contains(&parsed_deleted_msg) 81 | } else { 82 | false 83 | } 84 | }; 85 | 86 | // let botis = _ctx 87 | // .cache 88 | // .message(_channel_id, last_msg_id) 89 | // .await 90 | // .unwrap() 91 | // .author 92 | // .bot; 93 | 94 | if !msg_is_nqnbot 95 | && !Regex::new(r"^.react") 96 | .unwrap() 97 | .is_match(deleted_message.as_str()) 98 | && !Regex::new(r"^dsay ") 99 | .unwrap() 100 | .is_match(deleted_message.as_str()) 101 | // && !Regex::new(r":*:") 102 | // .unwrap() 103 | // .is_match(&deleted_message.as_str()) 104 | && !Regex::new(r"^.delete") 105 | .unwrap() 106 | .is_match(deleted_message.as_str()) 107 | { 108 | let settings = { 109 | ContentSafeOptions::default() 110 | .clean_channel(false) 111 | .clean_role(true) 112 | .clean_user(true) 113 | .clean_everyone(true) 114 | .clean_here(true) 115 | }; 116 | 117 | // Alert users who got mentioned. 118 | // for caps in Regex::new(r"(?P[0-9]{18}+)") 119 | // .unwrap() 120 | // .captures_iter(&deleted_message) 121 | // { 122 | // let user = &caps["url"]; 123 | // let hmm = &_ctx.cache.member(_guild_id, user).await.unwrap(); 124 | // } 125 | // End alert 126 | 127 | let mut content = content_safe(&_ctx.cache, &deleted_message, &settings, &[]); 128 | 129 | let mut proxied_content_attachments = Vec::new(); 130 | let mut content_attachments = Vec::new(); 131 | 132 | for caps in Regex::new(r"(?Phttps://cdn.discordapp.com/attachments/.*/.*)") 133 | .unwrap() 134 | .captures_iter(content.as_str()) 135 | { 136 | let url = &caps["url"]; 137 | 138 | content_attachments.push(caps["url"].to_string()); 139 | 140 | // Check if the file is an image 141 | let mut is_image = false; 142 | let extension_var = path::Path::new(&url).extension(); 143 | if extension_var.is_some() { 144 | let extension = extension_var.unwrap().to_string_lossy().to_string(); 145 | 146 | match extension.as_str() { 147 | "png" | "jpeg" | "jpg" | "webp" | "gif" => { 148 | is_image = true; 149 | } 150 | _ => {} 151 | } 152 | } 153 | 154 | if is_image { 155 | let params = [("image", url)]; 156 | let client = reqwest::Client::new() 157 | .post("https://api.imgur.com/3/image") 158 | .form(¶ms) 159 | .header("Authorization", "Client-ID ce8c306d711c6cf") 160 | .send() 161 | .await 162 | .unwrap() 163 | .text() 164 | .await 165 | .unwrap(); 166 | 167 | let client_data = Regex::new(r#"\\"#) 168 | .unwrap() 169 | .replace_all(client.as_str(), ""); 170 | 171 | for caps_next in Regex::new(r#"(?Phttps://.+?")"#) 172 | .unwrap() 173 | .captures_iter(&client_data) 174 | { 175 | // let link = caps_next["link"]; 176 | 177 | proxied_content_attachments 178 | .push(caps_next["link"].to_string().replace('"', "")); 179 | 180 | // println!("{}", link); 181 | 182 | // content_urls_new.push_str(&Regex::new(r#"""#).unwrap().replace(&link, "")); 183 | } 184 | } else { 185 | let current_dir = env::current_exe().unwrap(); 186 | let current_dir = ¤t_dir.parent().unwrap(); 187 | let file_name = path::Path::new(&url) 188 | .file_name() 189 | .unwrap() 190 | .to_string_lossy() 191 | .to_string(); 192 | 193 | process::Command::new("curl") 194 | .current_dir(¤t_dir) 195 | .args(&["-s", "-o", &file_name, url]) 196 | .status() 197 | .await 198 | .unwrap(); 199 | 200 | let client = process::Command::new("curl") 201 | .current_dir(¤t_dir) 202 | .args(&[ 203 | "-F".to_string(), 204 | format!("file=@{}", &file_name), 205 | "-F".to_string(), 206 | "no_index=false".to_string(), 207 | "https://api.anonymousfiles.io".to_string(), 208 | ]) 209 | .output() 210 | .await 211 | .unwrap(); 212 | 213 | fs::remove_file(format!( 214 | "{}/{}", 215 | ¤t_dir.to_string_lossy().to_string(), 216 | &file_name 217 | )) 218 | .await 219 | .unwrap(); 220 | 221 | let client = String::from_utf8_lossy(&client.stdout); 222 | 223 | let client_data = Regex::new(r#"\\"#).unwrap().replace_all(&client, ""); 224 | 225 | for caps_next in Regex::new(r#"(?Phttps://.+?")"#) 226 | .unwrap() 227 | .captures_iter(&client_data) 228 | { 229 | // let link = caps_next["link"]; 230 | 231 | proxied_content_attachments 232 | .push(caps_next["link"].to_string().replace('"', "")); 233 | 234 | // content_urls_new.push_str(&Regex::new(r#"""#).unwrap().replace(&link, "")); 235 | } 236 | } 237 | // content_new.push_str(&content.replace(&url, &content_urls_new)); 238 | } 239 | 240 | for var in proxied_content_attachments.iter() { 241 | content.push_str(format!("{} > `{}`", "\n", var).as_str()); 242 | // content.replace(&content_attachments[loop_times].to_string(), &var.to_string()) 243 | } 244 | 245 | let last_msg = &qq.first(); 246 | let last_msg_id = &last_msg.as_ref().map(|x| x.id); 247 | 248 | if last_msg_id.is_some() { 249 | let dbnode_delmsg_trigger = Database::from("delmsg_trigger".to_string()).await; 250 | dbnode.remove_msg(&_deleted_message_id).await; 251 | 252 | content = { 253 | if dbnode_delmsg_trigger.msg_exists(&_deleted_message_id).await { 254 | let file_path = format!("{}/{}", &dbnode_delmsg_trigger, &_deleted_message_id); 255 | let prev_content = fs::read_to_string(&file_path).await.unwrap(); 256 | format!("{}\n{}", &prev_content, &content) 257 | } else { 258 | content 259 | } 260 | }; 261 | 262 | content = { 263 | if dbnode_delmsg_trigger 264 | .msg_exists(&last_msg_id.unwrap()) 265 | .await 266 | { 267 | let file_path = format!("{}/{}", &dbnode_delmsg_trigger, &last_msg_id.unwrap()); 268 | let prev_content = fs::read_to_string(&file_path).await.unwrap(); 269 | format!("{}\n{}", &prev_content, &content) 270 | } else { 271 | content 272 | } 273 | }; 274 | 275 | dbnode_delmsg_trigger 276 | .save_msg(&last_msg_id.unwrap(), String::from(&content)) 277 | .await; 278 | 279 | last_msg 280 | .as_ref() 281 | .map(|x| async move { 282 | if (x.react(&_ctx.http, '📩').await).is_err() { 283 | // In case someone blocked the bot 284 | _channel_id 285 | .say(&_ctx, &content.replace("---MSG_TYPE---", "Deleted:")) 286 | .await 287 | .ok(); 288 | } 289 | }) 290 | .unwrap() 291 | .await; 292 | } else { 293 | _channel_id 294 | .say(&_ctx, &content.replace("---MSG_TYPE---", "Deleted:")) 295 | .await 296 | .ok(); 297 | } 298 | } 299 | 300 | // process::Command::new("find") 301 | // .args(&[ 302 | // dbnode.to_string(), 303 | // String::from("-type"), 304 | // String::from("f"), 305 | // String::from("-mtime"), 306 | // String::from("+5"), 307 | // String::from("-delete"), 308 | // ]) 309 | // .spawn() 310 | // .ok(); 311 | } 312 | -------------------------------------------------------------------------------- /src/event/message_update.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | pub async fn responder( 4 | _ctx: Context, 5 | _old_if_available: Option, 6 | _new: Option, 7 | _event: MessageUpdateEvent, 8 | ) { 9 | // let last_msg_id = _new 10 | // .unwrap() 11 | // .channel(&_ctx.cache) 12 | // .await 13 | // .unwrap() 14 | // .guild() 15 | // .unwrap() 16 | // .last_message_id 17 | // .unwrap(); 18 | 19 | // let dbnode = Database::from("msgcache".to_string()).await; 20 | 21 | // let map = json!({"content": dbnode.fetch_deleted_msg(_event.id).await.replace("---MSG_TYPE---", "Edited:")}); 22 | // _ctx.http 23 | // .send_message(u64::try_from(_event.channel_id).unwrap(), &map) 24 | // .await 25 | // .ok(); 26 | 27 | let _msg_id = _event.id; 28 | let _channel_id = _event.channel_id; 29 | 30 | if let Ok(message) = &_ctx 31 | .http 32 | .get_message( 33 | u64::try_from(_channel_id).unwrap(), 34 | u64::try_from(_msg_id).unwrap(), 35 | ) 36 | .await 37 | { 38 | if message.edited_timestamp.is_some() && message.webhook_id.is_none() { 39 | let dbnode = Database::from("msgcache".to_string()).await; 40 | let msg_content = &message.content; 41 | 42 | // let mut is_self_reacted = false; 43 | // for user in message.reactions.iter() { 44 | // if user.me { 45 | // is_self_reacted = true; 46 | // } 47 | // } 48 | 49 | // if !is_self_reacted && !message.is_own(&_ctx.cache).await { 50 | // message.react(&_ctx.http, '✍').await.ok(); 51 | // } 52 | let edit_time = &message.edited_timestamp.unwrap().format("%H:%M:%S %p"); 53 | let old_content = dbnode.fetch_msg(_msg_id).await; 54 | let new_content = format!( 55 | "{}\n> Edited at: {}\n{}", 56 | &msg_content, &edit_time, &old_content 57 | ); 58 | dbnode.save_msg(&_msg_id, new_content).await; 59 | // message.delete_reaction_emoji(&_ctx.http, '✍').await.unwrap(); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/event/mod.rs: -------------------------------------------------------------------------------- 1 | // mod getting_started; 2 | mod guild_create; 3 | // mod guild_member_addition; 4 | mod guild_member_removal; 5 | mod interaction_create; 6 | mod message; 7 | mod new_question; 8 | // mod reaction_add; 9 | mod ready; 10 | mod thread_update; 11 | 12 | use crate::utils::substr; 13 | 14 | use serde_json::json; 15 | 16 | use serenity::async_trait; 17 | use serenity::model::{ 18 | application::interaction::Interaction, 19 | channel::{GuildChannel, Message}, 20 | // event::MessageUpdateEvent, 21 | gateway::{Activity, Ready}, 22 | guild::{Guild, Member}, 23 | id::{ChannelId, GuildId}, 24 | prelude::User, 25 | }; 26 | 27 | use color_eyre::eyre::Result; 28 | use std::convert::TryFrom; 29 | use std::sync::atomic::{AtomicBool, Ordering}; 30 | 31 | // use thorne::english_gen; 32 | 33 | // questions_thread 34 | 35 | use serenity::client::{Context, EventHandler}; 36 | 37 | pub struct Listener { 38 | pub is_loop_running: AtomicBool, 39 | } 40 | 41 | #[async_trait] 42 | impl EventHandler for Listener { 43 | // Set a handler for the `message` event - so that whenever a new message 44 | // is received - the closure (or function) passed will be called. 45 | /// 46 | // Event handlers are dispatched through a threadpool, and so multiple 47 | // events can be dispatched simultaneously. 48 | 49 | async fn message(&self, _ctx: Context, _msg: Message) { 50 | message::responder(_ctx, _msg).await.unwrap(); 51 | } 52 | 53 | // async fn message_delete( 54 | // &self, 55 | // _ctx: Context, 56 | // _channel_id: ChannelId, 57 | // _deleted_message_id: MessageId, 58 | // _guild_id: Option, 59 | // ) { 60 | // message_delete::responder(_ctx, _channel_id, _deleted_message_id, _guild_id).await; 61 | // } 62 | 63 | // async fn message_update( 64 | // &self, 65 | // _ctx: Context, 66 | // _old_if_available: Option, 67 | // _new: Option, 68 | // _event: MessageUpdateEvent, 69 | // ) { 70 | // message_update::responder(_ctx, _old_if_available, _new, _event).await; 71 | // } 72 | 73 | async fn thread_create(&self, _ctx: Context, _thread: GuildChannel) { 74 | _thread.id.join_thread(&_ctx.http).await.unwrap(); 75 | } 76 | // Set a handler to be called on the `ready` event. This is called when a 77 | // shard is booted, and a READY payload is sent by Discord. This payload 78 | // contains data like the current user's guild Ids, current user data, 79 | // private channels, and more. 80 | // 81 | // In this case, just print what the current user's username is. 82 | async fn ready(&self, _ctx: Context, ready: Ready) { 83 | ready::responder(&_ctx, ready).await.unwrap(); 84 | } 85 | 86 | // async fn guild_member_addition(&self, _ctx: Context, _guild_id: GuildId, _new_member: Member) { 87 | // guild_member_addition::responder(_ctx, _guild_id, _new_member).await; 88 | // } 89 | 90 | async fn guild_member_removal( 91 | &self, 92 | _ctx: Context, 93 | _guild_id: GuildId, 94 | _user: User, 95 | _member_data_if_available: Option, 96 | ) { 97 | guild_member_removal::responder(_ctx, _guild_id, _user, _member_data_if_available).await; 98 | } 99 | 100 | // async fn reaction_add(&self, _ctx: Context, _added_reaction: Reaction) { 101 | // reaction_add::responder(_ctx, _added_reaction).await; 102 | // } 103 | 104 | // We use the cache_ready event just in case some cache operation is required in whatever use 105 | // case you have for this. 106 | async fn cache_ready(&self, _ctx: Context, _guilds: Vec) { 107 | println!("Cache built successfully!"); 108 | 109 | // it's safe to clone Context, but Arc is cheaper for this use case. 110 | // Untested claim, just theoretically. :P 111 | // let ctx = Arc::new(ctx); 112 | 113 | // We need to check that the loop is not already running when this event triggers, 114 | // as this event triggers every time the bot enters or leaves a guild, along every time the 115 | // ready shard event triggers. 116 | // 117 | // An AtomicBool is used because it doesn't require a mutable reference to be changed, as 118 | // we don't have one due to self being an immutable reference. 119 | if !self.is_loop_running.load(Ordering::Relaxed) { 120 | // We have to clone the Arc, as it gets moved into the new thread. 121 | // let ctx1 = Arc::clone(&ctx); 122 | // tokio::spawn creates a new green thread that can run in parallel with the rest of 123 | // the application. 124 | // tokio::spawn(async move { 125 | // loop { 126 | // // We clone Context again here, because Arc is owned, so it moves to the 127 | // // new function. 128 | // // log_system_load(Arc::clone(&ctx1)).await; 129 | // let dbnode_userid = Database::from("userid".to_string()).await; 130 | // let guilds = &ctx.cache.guilds().await; 131 | 132 | // for guild in guilds.iter() { 133 | // let members = &ctx1.cache.guild(guild).await.unwrap().members; 134 | 135 | // for (_user_id, _member) in members { 136 | // // tokio::time::sleep(Duration::from_secs(2)).await; 137 | // dbnode_userid 138 | // .save_user_info(_user_id, _member.user.tag()) 139 | // .await; 140 | // } 141 | // } 142 | 143 | // // Workaround process uptime limit on free google server 144 | // // tokio::time::sleep(Duration::from_secs(3 * (24 * (60 * 60)))).await; 145 | // // std::process::Command::new(env::current_exe().unwrap()) 146 | // // .spawn() 147 | // // .unwrap(); 148 | // // std::process::exit(0); 149 | // } 150 | // }); 151 | 152 | // Now that the loop is running, we set the bool to true 153 | self.is_loop_running.swap(true, Ordering::Relaxed); 154 | } 155 | } 156 | 157 | async fn thread_update(&self, _ctx: Context, _thread: GuildChannel) { 158 | thread_update::responder(_ctx, _thread).await.unwrap(); 159 | } 160 | 161 | async fn guild_create(&self, _ctx: Context, _guild: Guild, _is_new: bool) { 162 | guild_create::responder(_ctx, _guild, _is_new).await; 163 | } 164 | async fn interaction_create(&self, ctx: Context, interaction: Interaction) { 165 | interaction_create::responder(&ctx, interaction) 166 | .await 167 | .unwrap(); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/event/new_question.rs: -------------------------------------------------------------------------------- 1 | use crate::{init::MEILICLIENT_THREAD_INDEX, utils::index_threads::{Thread, index_thread_messages}}; 2 | use url::Url; 3 | 4 | use super::substr::StringUtils; 5 | use color_eyre::eyre::{eyre, Report, Result}; 6 | 7 | use regex::Regex; 8 | use serenity::{ 9 | client::Context, 10 | model::{application::component::ButtonStyle, channel::ReactionType}, 11 | prelude::Mentionable, 12 | }; 13 | use serenity::{ 14 | model::{guild::Emoji, prelude::Message}, 15 | utils::{read_image, MessageBuilder}, 16 | }; 17 | use std::{collections::HashMap, env, time::Duration, iter::repeat_with}; 18 | use tokio::time::sleep; 19 | use urlencoding::encode; 20 | 21 | async fn save_and_fetch_links( 22 | sites: &[&str], 23 | title: &str, 24 | _description: &str, 25 | ) -> Option> { 26 | let mut links: HashMap = HashMap::new(); 27 | 28 | let client = reqwest::Client::new(); 29 | 30 | // Fetch matching links 31 | for site in sites.iter() { 32 | if let Ok(resp) = client 33 | .get( 34 | format!( 35 | "https://www.google.com/search?q=site:{} {}", 36 | encode(site), 37 | encode(title) 38 | ) 39 | ) 40 | .header("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36") 41 | .send() 42 | .await { 43 | if let Ok(result) = resp.text().await { 44 | 45 | for (i, caps) in 46 | // [^:~] avoids the google hyperlinks 47 | Regex::new(format!("\"(?P{}/.[^:~]*?)\"", &site).as_str()) 48 | .unwrap() 49 | .captures_iter(&result).enumerate() 50 | { 51 | // 3 MAX each, starts at 0 52 | if i == 3 { 53 | break; 54 | } 55 | 56 | let url = &caps["url"]; 57 | let captured_hash = Regex::new(r"(?P#[^:~].*)").ok()? 58 | .captures(url) 59 | .and_then(|cap| { 60 | cap.name("hash") 61 | .map(|name| name.as_str()) 62 | }); 63 | 64 | if let Ok(resp) = client.get(url) 65 | .header("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/88.0.4324.182 Safari/537.36") 66 | .send() 67 | .await { 68 | if let Ok(result) = resp.text().await { 69 | let result = html_escape::decode_html_entities(&result).to_string(); 70 | for caps in Regex::new(r"(?P<title>.*?)").unwrap().captures_iter(&result) { 71 | let title = &caps["title"]; 72 | 73 | let text = if let Some(hash) = captured_hash { 74 | format!("{} | {}", title, hash) 75 | } else { 76 | title.to_string() 77 | }; 78 | //links.push_str(format!("• __{}__\n\n", text).as_str()); 79 | links.insert(text, url.to_string()); 80 | } 81 | } 82 | } 83 | 84 | } 85 | } 86 | } 87 | } 88 | 89 | // // Fetch 5 MAX matching discord questions 90 | if let Some(mclient) = MEILICLIENT_THREAD_INDEX.get() 91 | && let Ok(data) = mclient.search().with_query(title).with_limit(5).execute::().await { 92 | for ids in data.hits { 93 | links.insert( 94 | ids.result.title, 95 | format!( 96 | "https://discord.com/channels/{}/{}", 97 | ids.result.guild_id, ids.result.id 98 | ), 99 | ); 100 | } 101 | } 102 | 103 | Some(links) 104 | } 105 | 106 | pub async fn responder(ctx: &Context, msg: &Message) -> Result<(), Report> { 107 | // Config 108 | if let Some(config) = crate::BOT_CONFIG.get() && let Some(channels) = &config.discord.channels 109 | && let Some(primary_questions_channel) = channels.primary_questions_channel_id 110 | && let Some(secondary_questions_channel) = channels.secondary_questions_channel_id 111 | // Check if thread 112 | && let Some(thread) = msg.channel(&ctx.http).await?.guild() 113 | && let Some(parent_channel_id) = thread.parent_id 114 | && [primary_questions_channel, secondary_questions_channel].contains(&parent_channel_id) 115 | && msg.id.as_u64() == thread.id.as_u64() { 116 | 117 | let user_mention = &msg.author.mention(); 118 | let user_without_mention = &msg.author.name; 119 | 120 | // Message node 121 | thread 122 | .send_message(&ctx, |message| { 123 | message.content( 124 | MessageBuilder::new() 125 | .push_quote( 126 | format!( 127 | "Hey {}! Thank you for raising this — please hang tight as someone from our community may help you out.", 128 | &user_without_mention 129 | ) 130 | ).build() 131 | ); 132 | 133 | // Buttons 134 | message.components(|c| { 135 | c.create_action_row(|ar| { 136 | ar.create_button(|button| { 137 | button 138 | .style(ButtonStyle::Danger) 139 | .label("Close") 140 | .custom_id("gitpod_close_issue") 141 | .emoji(ReactionType::Unicode("🔒".to_string())) 142 | }); 143 | ar.create_button(|button| { 144 | button 145 | // .custom_id("gitpod_docs_link") 146 | .style(ButtonStyle::Link) 147 | .label("Docs") 148 | .emoji(ReactionType::Unicode("📚".to_string())) 149 | .url("https://www.gitpod.io/docs/") 150 | }); 151 | ar.create_button(|button| { 152 | button.style(ButtonStyle::Link).label("YouTube").url( 153 | "https://youtube.com/playlist?list=PL3TSF5whlprXVp-7Br2oKwQgU4bji1S7H", 154 | ).emoji(ReactionType::Unicode("📺".to_string())) 155 | }); 156 | // Final return 157 | ar.create_button(|button| { 158 | button 159 | .style(ButtonStyle::Link) 160 | .label("Status") 161 | .emoji(ReactionType::Unicode("🧭".to_string())) 162 | .url("https://www.gitpodstatus.com/") 163 | }) 164 | }) 165 | }); 166 | 167 | message 168 | 169 | }) 170 | .await?; 171 | 172 | // Simulate typing 173 | let thread_typing = thread.clone().start_typing(&ctx.http)?; 174 | 175 | // Fetch suggestions from relevant sources 176 | let relevant_links_external_sources = { 177 | if parent_channel_id != secondary_questions_channel { 178 | Vec::from(["https://www.gitpod.io/docs", "https://github.com/gitpod-io"]) 179 | } else { 180 | Vec::from(["https://github.com/gitpod-io"]) 181 | } 182 | }; 183 | 184 | if let Some(mut relevant_links) = save_and_fetch_links( 185 | &relevant_links_external_sources, 186 | &thread.name, 187 | &msg.content, 188 | ).await { 189 | 190 | let mut prefix_emojis: HashMap<&str, Emoji> = HashMap::new(); 191 | let emoji_sources: HashMap<&str, &str> = HashMap::from([ 192 | ("gitpod", "https://www.gitpod.io/images/media-kit/logo-mark.png"), 193 | ("github", "https://cdn.discordapp.com/attachments/981191970024210462/981192908780736573/github-transparent.png"), 194 | ("discord", "https://discord.com/assets/9f6f9cd156ce35e2d94c0e62e3eff462.png") 195 | ]); 196 | let guild = &msg.guild_id.ok_or_else(||eyre!("Failed to get GuildId"))?; 197 | for source in ["gitpod", "github", "discord"].iter() { 198 | let emoji = { 199 | if let Some(emoji) = guild 200 | .emojis(&ctx.http) 201 | .await? 202 | .into_iter() 203 | .find(|x| x.name == *source) 204 | { 205 | emoji 206 | } else { 207 | let dw_path = 208 | env::current_dir()?.join(format!("{source}.png")); 209 | let dw_url = emoji_sources.get(source) 210 | .ok_or_else(||eyre!("Emoji source {source} doesn't exist"))? 211 | .to_string(); 212 | let client = reqwest::Client::new(); 213 | let downloaded_bytes = client 214 | .get(dw_url) 215 | .timeout(Duration::from_secs(5)) 216 | .send() 217 | .await? 218 | .bytes() 219 | .await?; 220 | tokio::fs::write(&dw_path, &downloaded_bytes).await?; 221 | let emoji_image = read_image(dw_path)?; 222 | let emoji_image = emoji_image.as_str(); 223 | guild 224 | .create_emoji(&ctx.http, source, emoji_image) 225 | .await? 226 | } 227 | }; 228 | prefix_emojis.insert(source, emoji); 229 | } 230 | 231 | let mut suggested_block_count = 0; 232 | thread.send_message(&ctx.http, |m| { 233 | m.content(format!("{}, I found some relevant links which might help to self-serve, please do check them out below 🙏:", &user_without_mention)); 234 | m.components(|c| { 235 | // TODO: We could use a more concise `for` loop, but anyway 236 | loop { 237 | // 2 suggestion blocks MAX, means ~10 links 238 | if suggested_block_count == 2 || relevant_links.is_empty() { 239 | break; 240 | } else { 241 | suggested_block_count += 1; 242 | } 243 | 244 | c.create_action_row(|a| { 245 | for (i, (title, url)) in relevant_links.clone().iter().enumerate() { 246 | // 5 MAX, starts at 0 247 | if i == 5 { 248 | break; 249 | } else { 250 | relevant_links.remove(title); 251 | } 252 | let emoji = { 253 | if url.starts_with("https://www.gitpod.io") { 254 | prefix_emojis.get("gitpod").unwrap() 255 | } else if url.starts_with("https://github.com") { 256 | prefix_emojis.get("github").unwrap() 257 | } else { 258 | prefix_emojis.get("discord").unwrap() 259 | } 260 | }; 261 | 262 | if let Ok(mut parsed_url) = Url::parse(url) { 263 | 264 | let random_str: String = repeat_with(fastrand::alphanumeric).take(4).collect(); 265 | parsed_url.query_pairs_mut().append_key_only(&random_str); 266 | 267 | a.create_button(|b| b.label(title.as_str().substring(0, 80)) 268 | .custom_id(parsed_url.as_str().substring(0, 100)) 269 | .style(ButtonStyle::Secondary) 270 | .emoji(ReactionType::Custom { 271 | id: emoji.id, 272 | name: Some(emoji.name.clone()), 273 | animated: false, 274 | }) 275 | ); 276 | } 277 | // .query_pairs_mut() 278 | // .append_key_only(&random_str) 279 | // .finish(); 280 | 281 | } 282 | a 283 | }); 284 | } 285 | c 286 | }); 287 | m 288 | }).await?; 289 | } 290 | 291 | // Index to DB 292 | index_thread_messages(ctx, &vec![thread.clone()]) 293 | .await 294 | .ok(); 295 | 296 | // Take a pause 297 | sleep(Duration::from_secs(5)).await; 298 | 299 | let mut msg = MessageBuilder::new(); 300 | // Ask for info 301 | msg.push_quote_line(format!( 302 | "{} **{}**", 303 | &user_mention, "You can share the following (if applies):" 304 | )); 305 | 306 | if parent_channel_id != secondary_questions_channel { 307 | msg.push_line("• Contents of your `.gitpod.yml`") 308 | .push_line("• Contents of your `.gitpod.Dockerfile`") 309 | .push_line("• An example repository link"); 310 | } else { 311 | msg.push_line("• Contents of your `config.yml`") 312 | .push("• Result of:```bash\nkubectl get pods -n ```") 313 | .push_line("• If you have resources that are set up strangely, please run `kubectl describe` on the resource"); 314 | } 315 | 316 | // AI prompt 317 | msg.push_line("\n> ✨ **NEW:** Try our experimental Gitpod Docs AI!"); 318 | 319 | thread 320 | .send_message(&ctx.http, |message| { 321 | message.content(msg.build()); 322 | 323 | message.components(|c| { 324 | c.create_action_row(|ar| { 325 | ar.create_button(|button| { 326 | button 327 | .style(ButtonStyle::Primary) 328 | .label("Ask Gitpod Docs AI") 329 | .custom_id("question-qa") 330 | .emoji(ReactionType::Unicode("🔍".to_string())) 331 | }) 332 | }) 333 | }); 334 | message 335 | }) 336 | .await?; 337 | 338 | thread_typing.stop().ok_or_else(|| eyre!("Couldn't stop writing"))?; 339 | }; 340 | 341 | Ok(()) 342 | } 343 | -------------------------------------------------------------------------------- /src/event/reaction_add.rs: -------------------------------------------------------------------------------- 1 | use serenity::model::Permissions; 2 | use substr::StringUtils; 3 | 4 | use super::*; 5 | 6 | pub async fn responder(_ctx: Context, _added_reaction: Reaction) { 7 | let emoji = &_added_reaction.emoji.to_string(); 8 | let reacted_user = &_added_reaction.user(&_ctx.http).await.unwrap(); 9 | if reacted_user.bot { 10 | return; 11 | } 12 | let react_data = &_added_reaction.message(&_ctx.http).await.unwrap(); 13 | 14 | let is_self_msg = &react_data.is_own(&_ctx.cache); 15 | // let reactions_count = react_data.reactions.iter().count(); 16 | let reactions = &react_data.reactions; 17 | 18 | let mut is_self_reacted = false; 19 | for user in reactions.iter() { 20 | if user.me { 21 | is_self_reacted = true; 22 | } 23 | } 24 | 25 | match emoji.as_str() { 26 | // "✍" => { 27 | // if !*a_bot_reacted_now && is_self_reacted { 28 | // react_data 29 | // .delete_reaction_emoji(&_ctx.http, '✍') 30 | // .await 31 | // .unwrap(); 32 | 33 | // let dbnode = Database::from("msgcache".to_string()).await; 34 | // // Use contentsafe options 35 | // let settings = { 36 | // ContentSafeOptions::default() 37 | // .clean_channel(false) 38 | // .clean_role(true) 39 | // .clean_user(true) 40 | // .clean_everyone(true) 41 | // .clean_here(true) 42 | // }; 43 | 44 | // let content = content_safe( 45 | // &_ctx.cache, 46 | // dbnode.fetch_msg(_added_reaction.message_id).await, 47 | // &settings, 48 | // ) 49 | // .await; 50 | 51 | // react_data 52 | // .reply( 53 | // &_ctx.http, 54 | // &content 55 | // .replace( 56 | // "---MSG_TYPE---", 57 | // format!("Triggered: {} `||` Edited:", &reacted_user).as_str(), 58 | // ) 59 | // .as_str() 60 | // .substring(0, 2000), 61 | // ) 62 | // .await 63 | // .unwrap() 64 | // .react(&_ctx.http, '🔃') 65 | // .await 66 | // .unwrap(); 67 | 68 | // // let msg_content = &react_data.content; 69 | // // let edit_time = &react_data.edited_timestamp.unwrap().format("%H:%M:%S %p"); 70 | // // let old_content = dbnode.fetch_msg(react_data.id).await; 71 | // // let new_content = format!( 72 | // // "{}\n> Edited at: {}\n {}", 73 | // // &msg_content, &edit_time, &old_content 74 | // // ); 75 | // // dbnode.save_msg(&react_data.id, new_content).await; 76 | // } 77 | // } 78 | 79 | // Deleted message handlers and or listeners 80 | "📩" => { 81 | if is_self_reacted { 82 | let roles = &_added_reaction.member.unwrap().roles; 83 | let is_owner = _added_reaction 84 | .guild_id 85 | .unwrap() 86 | .to_partial_guild(&_ctx) 87 | .await 88 | .unwrap() 89 | .owner_id 90 | == reacted_user.id; 91 | let mut got_admin = false; 92 | 93 | for role in roles { 94 | if role 95 | .to_role_cached(&_ctx.cache) 96 | .map_or(false, |r| r.has_permission(Permissions::ADMINISTRATOR)) 97 | { 98 | got_admin = true; 99 | break; 100 | } 101 | } 102 | if got_admin || is_owner { 103 | react_data 104 | .delete_reaction_emoji(&_ctx.http, '📩') 105 | .await 106 | .unwrap(); 107 | 108 | let dbnode = Database::from("delmsg_trigger".to_string()).await; 109 | 110 | let content = dbnode.fetch_msg(_added_reaction.message_id).await.replace( 111 | "---MSG_TYPE---", 112 | format!("Triggered: {} `||` Deleted:", &reacted_user).as_str(), 113 | ); 114 | 115 | react_data 116 | .reply(&_ctx.http, &content.as_str().substring(0, 2000)) 117 | .await 118 | .unwrap() 119 | .react(&_ctx.http, '🔃') 120 | .await 121 | .unwrap(); 122 | } 123 | } 124 | } 125 | 126 | "🔃" => { 127 | if *is_self_msg && is_self_reacted { 128 | react_data 129 | .delete_reaction_emoji(&_ctx.http, '🔃') 130 | .await 131 | .unwrap(); 132 | react_data.delete(&_ctx.http).await.unwrap(); 133 | 134 | let target_emoji = { 135 | if react_data.content.to_string().contains("`||` Edited: ") { 136 | '✍' 137 | } else { 138 | '📩' 139 | } 140 | }; 141 | 142 | react_data 143 | .referenced_message 144 | .as_ref() 145 | .map(|x| async move { 146 | x.react(&_ctx.http, target_emoji).await.unwrap(); 147 | }) 148 | .unwrap() 149 | .await; 150 | } 151 | } 152 | 153 | "❎" => { 154 | if is_self_reacted && *is_self_msg { 155 | react_data.delete(&_ctx.http).await.unwrap(); 156 | } 157 | } 158 | _ => {} 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /src/event/ready.rs: -------------------------------------------------------------------------------- 1 | use color_eyre::Report; 2 | use serenity::{ 3 | futures::StreamExt, 4 | model::{ 5 | application::{command::Command, component::ButtonStyle}, 6 | prelude::{ 7 | command::{CommandOptionType, CommandType}, 8 | GuildInfo, ReactionType, 9 | }, 10 | Permissions, 11 | }, 12 | }; 13 | 14 | use super::*; 15 | 16 | struct Init; 17 | impl Init { 18 | async fn set_app_cmds(guild: &GuildInfo, ctx: &Context) -> Result, Report> { 19 | let cmds = GuildId::set_application_commands(&guild.id, &ctx.http, |commands| { 20 | commands.create_application_command(|command| { 21 | command.name("close").description("Close a question") 22 | }); 23 | 24 | commands.create_application_command(|command| { 25 | command 26 | .name("create-pr") 27 | .description("Pull this thread into GitHub to merge into website") 28 | .kind(CommandType::ChatInput) 29 | .default_member_permissions(Permissions::ADMINISTRATOR) 30 | .create_option(|opt| { 31 | opt.kind(CommandOptionType::String) 32 | .name("link") 33 | .description("Page link to a https://www.gitpod.io/docs/") 34 | .required(true) 35 | }) 36 | .create_option(|opt| { 37 | opt.kind(CommandOptionType::String) 38 | .name("title") 39 | .description("Title of the FAQ") 40 | .required(false) 41 | }) 42 | .create_option(|opt| { 43 | opt.kind(CommandOptionType::Boolean) 44 | .name("gpt4") 45 | .required(false) 46 | .default_option(false) 47 | .description("Use GPT4 instead of GPT3.5") 48 | }) 49 | }); 50 | 51 | commands.create_application_command(|c| { 52 | c.name("nothing_to_see_here") 53 | .description("Nope :P") 54 | .kind(CommandType::ChatInput) 55 | .default_member_permissions(Permissions::ADMINISTRATOR) 56 | .create_option(|opt| { 57 | opt.kind(CommandOptionType::String) 58 | .name("value") 59 | .description(";-;") 60 | .required(true) 61 | }) 62 | }); 63 | 64 | commands 65 | }) 66 | .await?; 67 | 68 | Ok(cmds) 69 | } 70 | 71 | async fn install_getting_started_message(ctx: &Context) -> Result<()> { 72 | let placeholder_text = "**Press the button below** 👇 to gain access to the server"; 73 | 74 | if let Some(config) = crate::BOT_CONFIG.get() && let Some(channels) = &config.discord.channels 75 | && let Some(getting_started_channel) = channels.getting_started_channel_id { 76 | 77 | let mut cursor = getting_started_channel.messages_iter(&ctx.http).boxed(); 78 | while let Some(message_result) = cursor.next().await { 79 | if let Ok(message) = message_result &&message.content == *placeholder_text { 80 | return Ok(()) 81 | } 82 | } 83 | 84 | getting_started_channel 85 | .send_message(&ctx.http, |m| { 86 | m.content(placeholder_text); 87 | m.components(|c| { 88 | c.create_action_row(|a| { 89 | a.create_button(|b| { 90 | b.label("Let's go") 91 | .custom_id("getting_started_letsgo") 92 | .style(ButtonStyle::Primary) 93 | .emoji(ReactionType::Unicode("🙌".to_string())) 94 | }) 95 | }) 96 | }); 97 | m 98 | }) 99 | .await?; 100 | } 101 | 102 | Ok(()) 103 | } 104 | } 105 | 106 | // Main entrypoint 107 | pub async fn responder(ctx: &Context, ready: Ready) -> Result<()> { 108 | println!("{} is connected!", ready.user.name); 109 | ctx.set_activity(Activity::watching("The pods on Gitpod!")) 110 | .await; 111 | 112 | let guilds = &ready.user.guilds(&ctx.http).await?; 113 | for guild in guilds { 114 | Init::set_app_cmds(guild, ctx).await?; 115 | } 116 | 117 | Init::install_getting_started_message(ctx).await?; 118 | 119 | Ok(()) 120 | } 121 | -------------------------------------------------------------------------------- /src/event/thread_update.rs: -------------------------------------------------------------------------------- 1 | use crate::utils::index_threads::index_thread_messages; 2 | use color_eyre::eyre::{eyre, Result}; 3 | use regex::Regex; 4 | use serenity::{client::Context, model::channel::GuildChannel, model::channel::MessageType}; 5 | 6 | // Was trying to hook into auto thread archival and ask the participants 7 | // if the thread was resolved but guess we can't reliably do it now 8 | // since there is no reliable way to detect who triggered thread_update 9 | // Tl;dr : Discord API doesn't tell you who archived the thread, which is a big issue. 10 | async fn unarchival_action(_ctx: Context, _thread: GuildChannel) -> Result<()> { 11 | // _thread 12 | // .say( 13 | // &_ctx.http, 14 | // "Whoever is trying to archive from the Discord UI, please send `/close` as a message here instead.", 15 | // ) 16 | // .await 17 | // ?; 18 | _thread 19 | .edit_thread(&_ctx.http, |t| t.archived(false)) 20 | .await?; 21 | 22 | Ok(()) 23 | } 24 | 25 | pub async fn responder(ctx: Context, thread: GuildChannel) -> Result<()> { 26 | if let Some(config) = crate::BOT_CONFIG.get() && let Some(channels) = &config.discord.channels 27 | && let Some(primary_questions_channel) = channels.primary_questions_channel_id 28 | && let Some(secondary_questions_channel) = channels.secondary_questions_channel_id { 29 | 30 | let thread_type = { 31 | if [primary_questions_channel, secondary_questions_channel].contains( 32 | &thread 33 | .parent_id 34 | .ok_or_else(|| eyre!("Couldn't get parent_id of thread"))?, 35 | ) { 36 | "question" 37 | } else { 38 | "thread" 39 | } 40 | }; 41 | let last_msg = &ctx.http.get_messages(*thread.id.as_u64(), "").await?; 42 | let last_msg = last_msg 43 | .first() 44 | .ok_or_else(|| eyre!("Couldn't get last message"))?; 45 | 46 | if thread_type == "question" { 47 | // Index to DB 48 | index_thread_messages(&ctx, &vec![thread.clone()]) 49 | .await 50 | .ok(); 51 | 52 | if thread 53 | .thread_metadata 54 | .ok_or_else(|| eyre!("Couldn't get thread_metadata"))? 55 | .archived 56 | && last_msg.is_own(&ctx.cache) 57 | { 58 | if !(last_msg.kind.eq(&MessageType::GroupNameUpdate) 59 | || Regex::new("^This [a-z]+ was closed ?b?y?")?.is_match(last_msg.content.as_str())) 60 | { 61 | unarchival_action(ctx, thread).await?; 62 | } 63 | } else if thread 64 | .thread_metadata 65 | .ok_or_else(|| eyre!("Couldn't get thread_metadata"))? 66 | .archived 67 | { 68 | unarchival_action(ctx, thread).await?; 69 | } 70 | } 71 | } 72 | 73 | Ok(()) 74 | } 75 | -------------------------------------------------------------------------------- /src/init.rs: -------------------------------------------------------------------------------- 1 | use crate::config::MeilisearchConfig; 2 | use color_eyre::eyre::{eyre, Result}; 3 | use meilisearch_sdk::{client::Client, indexes::Index, settings::Settings}; 4 | use once_cell::sync::OnceCell; 5 | use sysinfo::{System, SystemExt}; 6 | use tokio::time::{sleep, Duration, Instant}; 7 | pub static MEILICLIENT_THREAD_INDEX: OnceCell = OnceCell::new(); 8 | 9 | fn remove_scheme_from_url(url_str: &str) -> Result { 10 | let url = url::Url::parse(url_str)?; 11 | let host = url.host_str().ok_or_else(|| eyre!("Invalid host {url}"))?; 12 | let path = url.path().trim_end_matches('/'); 13 | let port = url.port().ok_or_else(|| eyre!("Invalid port {url}"))?; 14 | 15 | Ok(format!("{host}{path}:{port}")) 16 | } 17 | 18 | pub async fn meilisearch(meili: &MeilisearchConfig) -> Result<()> { 19 | let meili_api_endpoint = &meili.api_endpoint; 20 | 21 | let meili_api_endpoint_without_scheme = remove_scheme_from_url(meili_api_endpoint)?; 22 | let meili_api_endpoint_without_scheme = meili_api_endpoint_without_scheme.as_str(); 23 | 24 | let mclient = Client::new(meili_api_endpoint, Some(&meili.master_key)); 25 | 26 | let mut system = System::new_all(); 27 | system.refresh_processes(); 28 | if system 29 | .processes_by_name("meilisearch") 30 | .peekable() 31 | .peek() 32 | .is_none() 33 | { 34 | // Get executable path and parent dir 35 | let exec_path = std::env::current_exe()?; 36 | let exec_parent_dir = exec_path 37 | .parent() 38 | .ok_or_else(|| eyre!("Failed to get parent dir of self"))?; 39 | 40 | // Update PATH 41 | if let Ok(value) = std::env::var("PATH") { 42 | std::env::set_var( 43 | "PATH", 44 | format!("{}:{value}", exec_parent_dir.to_string_lossy()), 45 | ); 46 | } 47 | 48 | let mut server_cmd = meili.server_cmd.clone(); 49 | // Add db-path if not specified 50 | if !&server_cmd.contains(&"--db-path".to_owned()) { 51 | let db_dir = exec_parent_dir 52 | .join("data.ms") 53 | .to_string_lossy() 54 | .to_string(); 55 | server_cmd.extend(vec!["--db-path".to_owned(), db_dir]); 56 | } 57 | 58 | // Start the meilisearch server 59 | std::process::Command::new(&server_cmd[0]) 60 | .args(&server_cmd[1..]) 61 | .args(["--log-level", "WARN"]) 62 | .args(["--http-addr", meili_api_endpoint_without_scheme]) 63 | .args(["--master-key", &meili.master_key]) 64 | .current_dir(exec_parent_dir) 65 | .spawn()?; 66 | 67 | // Await for the server to be fully started 68 | let start = Instant::now(); 69 | while !mclient.is_healthy().await { 70 | if start.elapsed() > Duration::from_secs(1000) { 71 | return Err(eyre!("Timeout waiting for server.")); 72 | } 73 | println!("Awaiting for Meilisearch to be up ..."); 74 | sleep(Duration::from_millis(300)).await; 75 | } 76 | } else { 77 | eprintln!("Meilisearch server is already running"); 78 | } 79 | 80 | let msettings = Settings::new() 81 | .with_searchable_attributes(["title", "messages", "tags", "author_id", "id"]) 82 | .with_filterable_attributes(["timestamp", "tags"]) 83 | .with_sortable_attributes(["timestamp"]) 84 | .with_distinct_attribute("title"); 85 | 86 | let threads_index_db = { 87 | let index_uid = "threads"; 88 | if let Ok(res) = mclient.get_index(index_uid).await { 89 | res 90 | } else { 91 | let task = mclient.create_index(index_uid, None).await?; 92 | let task = task.wait_for_completion(&mclient, None, None).await?; 93 | let task = task 94 | .try_make_index(&mclient) 95 | .ok() 96 | .ok_or_else(|| eyre!("Can't make index"))?; 97 | task.set_settings(&msettings).await?; 98 | task 99 | } 100 | }; 101 | 102 | MEILICLIENT_THREAD_INDEX 103 | .set(threads_index_db) 104 | .ok() 105 | .ok_or_else(|| eyre!("Can't cache the meiliclient index"))?; 106 | 107 | println!("Meilisearch is now fully up and healthy."); 108 | 109 | Ok(()) 110 | } 111 | 112 | pub fn tracing() -> Result<()> { 113 | use tracing_error::ErrorLayer; 114 | use tracing_subscriber::prelude::*; 115 | use tracing_subscriber::{fmt, EnvFilter}; 116 | 117 | let fmt_layer = fmt::layer().with_target(true).pretty(); 118 | let filter_layer = EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("warn"))?; 119 | 120 | tracing_subscriber::registry() 121 | .with(filter_layer) 122 | .with(fmt_layer) 123 | .with(ErrorLayer::default()) 124 | .init(); 125 | Ok(()) 126 | } 127 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #![feature(let_chains)] 2 | 3 | mod command; 4 | mod config; 5 | mod event; 6 | mod init; 7 | mod utils; 8 | 9 | use color_eyre::eyre::Result; 10 | use command::*; 11 | use once_cell::sync::OnceCell; 12 | use serenity::{ 13 | framework::standard::{buckets::LimitedFor, StandardFramework}, 14 | http::Http, 15 | prelude::*, 16 | }; 17 | use std::{ 18 | collections::{HashMap, HashSet}, 19 | path::Path, 20 | sync::{atomic::AtomicBool, Arc}, 21 | }; 22 | 23 | static BOT_CONFIG: OnceCell = OnceCell::new(); 24 | 25 | #[tokio::main] 26 | async fn main() -> Result<()> { 27 | // Tracing 28 | init::tracing()?; 29 | color_eyre::install()?; 30 | 31 | // Read BotConfig.toml 32 | let config_path = std::env::args() 33 | .nth(1) 34 | .unwrap_or_else(|| "BotConfig.toml".to_owned()); 35 | let config = BOT_CONFIG.get_or_try_init(|| config::read(Path::new(&config_path)))?; 36 | 37 | // Init meilisearch 38 | if let Some(meili) = &config.meilisearch { 39 | init::meilisearch(meili).await?; 40 | } 41 | 42 | let http = Http::new(&config.discord.bot_token); 43 | let (owners, bot_id) = match http.get_current_application_info().await { 44 | Ok(info) => { 45 | let mut owners = HashSet::new(); 46 | if let Some(team) = info.team { 47 | owners.insert(team.owner_user_id); 48 | } else { 49 | owners.insert(info.owner.id); 50 | } 51 | match http.get_current_user().await { 52 | Ok(bot_id) => (owners, bot_id.id), 53 | Err(why) => panic!("Could not access the bot id: {:?}", why), 54 | } 55 | } 56 | Err(why) => panic!("Could not access application info: {:?}", why), 57 | }; 58 | 59 | let framework = StandardFramework::new() 60 | .configure(|c| { 61 | c.with_whitespace(true) 62 | .on_mention(Some(bot_id)) 63 | .prefix("gp") 64 | // In this case, if "," would be first, a message would never 65 | // be delimited at ", ", forcing you to trim your arguments if you 66 | // want to avoid whitespaces at the start of each. 67 | .delimiters(vec![", ", ","]) 68 | // Sets the bot's owners. These will be used for commands that 69 | // are owners only. 70 | .owners(owners) 71 | }) 72 | // Set a function to be called prior to each command execution. This 73 | // provides the context of the command, the message that was received, 74 | // and the full name of the command that will be called. 75 | // 76 | // Avoid using this to determine whether a specific command should be 77 | // executed. Instead, prefer using the `#[check]` macro which 78 | // gives you this functionality. 79 | // 80 | // **Note**: Async closures are unstable, you may use them in your 81 | // application if you are fine using nightly Rust. 82 | // If not, we need to provide the function identifiers to the 83 | // hook-functions (before, after, normal, ...). 84 | .before(before) 85 | // Similar to `before`, except will be called directly _after_ 86 | // command execution. 87 | ////// .after(after) 88 | // Set a function that's called whenever an attempted command-call's 89 | // command could not be found. 90 | ////// .unrecognised_command(unknown_command) 91 | // Set a function that's called whenever a message is not a command. 92 | ////// .normal_message(normal_message) 93 | // Set a function that's called whenever a command's execution didn't complete for one 94 | // reason or another. For example, when a user has exceeded a rate-limit or a command 95 | // can only be performed by the bot owner. 96 | // .on_dispatch_error(dispatch_error) 97 | // Can't be used more than once per 5 seconds: 98 | .bucket("emoji", |b| b.delay(5)) 99 | .await 100 | // Can't be used more than 2 times per 30 seconds, with a 5 second delay applying per channel. 101 | // Optionally `await_ratelimits` will delay until the command can be executed instead of 102 | // cancelling the command invocation. 103 | .bucket("complicated", |b| { 104 | b.limit(2) 105 | .time_span(30) 106 | .delay(5) 107 | // The target each bucket will apply to. 108 | .limit_for(LimitedFor::Channel) 109 | // The maximum amount of command invocations that can be delayed per target. 110 | // Setting this to 0 (default) will never await/delay commands and cancel the invocation. 111 | .await_ratelimits(1) 112 | // A function to call when a rate limit leads to a delay. 113 | .delay_action(delay_action) 114 | }) 115 | .await 116 | // The `#[group]` macro generates `static` instances of the options set for the group. 117 | // They're made in the pattern: `#name_GROUP` for the group instance and `#name_GROUP_OPTIONS`. 118 | // #name is turned all uppercase 119 | .help(&MY_HELP) 120 | .group(&GENERAL_GROUP); 121 | // .group(&NOTE_GROUP); 122 | ////// .group(&EMOJI_GROUP) 123 | ////// .group(&MATH_GROUP) 124 | // .group(&OWNER_GROUP) 125 | 126 | let mut client = Client::builder(&config.discord.bot_token, GatewayIntents::default()) 127 | // .application_id(config.discord.application_id) 128 | .event_handler(event::Listener { 129 | is_loop_running: AtomicBool::new(false), 130 | }) 131 | .intents(GatewayIntents::all()) 132 | .framework(framework) 133 | .await 134 | .expect("Err creating client"); 135 | 136 | { 137 | let mut data = client.data.write().await; 138 | data.insert::(HashMap::default()); 139 | data.insert::(Arc::clone(&client.shard_manager)); 140 | } 141 | 142 | if let Err(why) = client.start().await { 143 | println!("Client error: {:?}", why); 144 | } 145 | 146 | Ok(()) 147 | } 148 | -------------------------------------------------------------------------------- /src/utils/index_threads.rs: -------------------------------------------------------------------------------- 1 | use crate::init::MEILICLIENT_THREAD_INDEX; 2 | use color_eyre::eyre::{eyre, Report, Result}; 3 | use regex::Regex; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use serenity::{ 7 | client::Context, 8 | futures::StreamExt, 9 | model::{ 10 | id::ChannelId, 11 | prelude::{GuildChannel, MessageType}, 12 | Timestamp, 13 | }, 14 | utils::{content_safe, ContentSafeOptions}, 15 | }; 16 | 17 | #[derive(Serialize, Deserialize, Debug)] 18 | pub struct Thread { 19 | pub title: String, 20 | pub messages: Vec, 21 | pub tags: Vec, 22 | pub author_id: u64, 23 | pub id: u64, 24 | pub guild_id: u64, 25 | 26 | pub parent_channel_id: u64, 27 | pub timestamp: i64, 28 | pub date: Timestamp, 29 | pub poster: String, // author discord avatar 30 | } 31 | 32 | pub async fn index_channel_threads(ctx: &Context, channel_ids: &[ChannelId]) -> Result<(), Report> { 33 | // let channel_id = ChannelId(1026115789721444384); 34 | // let guild_id = GuildId(947769443189129236); 35 | 36 | for channel_id in channel_ids { 37 | // Get archived threads from channel_id 38 | let archived_threads = channel_id 39 | .get_archived_public_threads(&ctx.http, None, None) 40 | .await? 41 | .threads; 42 | 43 | // Iterate over archived threads (AKA posts) from the (forum) channel 44 | index_thread_messages(ctx, &archived_threads).await?; 45 | } 46 | 47 | Ok(()) 48 | } 49 | 50 | pub async fn index_thread_messages( 51 | ctx: &Context, 52 | threads: &Vec, 53 | ) -> Result<(), Report> { 54 | for thread in threads { 55 | // Gather some data about the thread 56 | let thread_node = thread 57 | .id 58 | .to_channel(&ctx.http) 59 | .await? 60 | .guild() 61 | .ok_or_else(|| eyre!("Failed to get thread node"))?; 62 | let thread_id = thread_node.id; 63 | let guild_id = thread_node.guild_id; 64 | let thread_parent_channel_id = thread_node 65 | .parent_id 66 | .ok_or_else(|| eyre!("Failed to get parent_id of thread"))?; 67 | let thread_title = thread_node.name; 68 | let thread_author_id = thread_node 69 | .owner_id 70 | .ok_or_else(|| eyre!("Failed to get owner_id of thread"))?; 71 | let thread_author_avatar_url = guild_id 72 | .member(&ctx.http, thread_author_id) 73 | .await? 74 | .user 75 | .face(); 76 | 77 | // Get tags 78 | // TODO: How to optimize this, and better visualize this problem in mind, ask Thomas. 79 | // IDEA: Map available_tags into a hashmap, id to key and tag to value. 80 | let thread_available_tags = thread_parent_channel_id 81 | .to_channel(&ctx.http) 82 | .await? 83 | .guild() 84 | .ok_or_else(|| eyre!("Fauled to get parent guild channel"))? 85 | .available_tags; 86 | 87 | let thread_tags = thread_node 88 | .applied_tags 89 | .into_iter() 90 | .filter_map(|tag| thread_available_tags.iter().find(|avt| avt.id == tag)) 91 | .map(|x| x.name.to_owned()) 92 | .collect::>(); 93 | 94 | let thread_timestamp = { 95 | let meta = thread_node 96 | .thread_metadata 97 | .ok_or_else(|| eyre!("Cant fetch metadata"))?; 98 | if let Some(time) = meta.create_timestamp { 99 | time 100 | } else if let Some(time) = meta.archive_timestamp { 101 | time 102 | } else { 103 | thread_node.id.created_at() 104 | } 105 | }; 106 | 107 | // Get thread users 108 | /* let thread_user_ids: Vec = thread_id 109 | .get_thread_members(&ctx.http) 110 | .await? 111 | .iter() 112 | .filter_map(|m| m.user_id) 113 | .collect(); 114 | 115 | let mut thread_users: Vec = Vec::new(); 116 | for thread_member in thread_user_ids { 117 | if let Ok(member) = guild_id.member(&ctx.http, thread_member).await { 118 | thread_users.push(member.user); 119 | } 120 | } */ 121 | 122 | // loop inside thread 123 | let mut sanitized_messages: Vec = Vec::new(); 124 | let mut thread_messages_iter = thread_id.messages_iter(&ctx.http).boxed(); 125 | while let Some(message_item) = thread_messages_iter.next().await && let Ok(message) = message_item { 126 | 127 | // Skip if bot or system 128 | if message.author.bot || message.kind.eq(&MessageType::GroupNameUpdate) { 129 | continue; 130 | } 131 | 132 | // Collect the attachments 133 | let attachments = &message 134 | .attachments 135 | .into_iter() 136 | .map(|a| format!("{}\n", a.url)) 137 | .collect::(); 138 | 139 | let content = content_safe(&ctx.cache, &message.content, &ContentSafeOptions::default(), &[]); 140 | let content = Regex::new(r#"```"#)?.replace(&content, "\n```"); 141 | 142 | if attachments.is_empty() { 143 | sanitized_messages.push(format!( 144 | "**{}#{}**: {content}", 145 | message.author.name, message.author.discriminator 146 | )); 147 | } else { 148 | sanitized_messages.push(format!( 149 | "**{}#{}**: {content}\n{attachments}", 150 | message.author.name, message.author.discriminator 151 | )); 152 | } 153 | } 154 | 155 | // Fix message order 156 | sanitized_messages.reverse(); 157 | 158 | MEILICLIENT_THREAD_INDEX 159 | .get() 160 | .ok_or_else(|| eyre!("Failed to get meiliclient"))? 161 | .add_documents( 162 | &[Thread { 163 | title: thread_title, 164 | messages: sanitized_messages, 165 | tags: thread_tags, 166 | author_id: thread_author_id.into(), 167 | id: thread_id.into(), 168 | guild_id: *guild_id.as_u64(), 169 | parent_channel_id: thread_parent_channel_id.into(), 170 | timestamp: thread_timestamp.unix_timestamp(), 171 | date: thread_timestamp, 172 | poster: thread_author_avatar_url, 173 | }], 174 | Some("id"), 175 | ) 176 | .await?; 177 | } 178 | Ok(()) 179 | } 180 | -------------------------------------------------------------------------------- /src/utils/misc.rs: -------------------------------------------------------------------------------- 1 | pub fn vowel_gen(sentense: &str) -> &str { 2 | let first_char = sentense.chars().next(); 3 | let mut is_vowel = false; 4 | 5 | if let Some(first_char) = first_char { 6 | for vowel in ["a", "e", "i", "o", "u"].iter() { 7 | let first_char_low = first_char.to_lowercase().to_string(); 8 | let vowel_string = vowel.to_string(); 9 | 10 | if first_char_low == vowel_string { 11 | is_vowel = true; 12 | break; 13 | } 14 | } 15 | } 16 | 17 | if is_vowel { 18 | "An" 19 | } else { 20 | "A" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | // pub mod db; 2 | pub mod index_threads; 3 | // pub mod misc; 4 | pub mod parser; 5 | pub mod substr; 6 | -------------------------------------------------------------------------------- /src/utils/parser.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use serenity::{client::Context, framework::standard::Args, model::channel::Message}; 3 | 4 | pub struct Parse; 5 | 6 | impl Parse { 7 | pub async fn user(_ctx: &Context, _message: &Message, _arguments: &Args) -> u64 { 8 | if _arguments.rest().is_empty() { 9 | *_message.author.id.as_u64() 10 | } else { 11 | let re = Regex::new(r#"\W"#).unwrap(); 12 | let pstr = &_arguments.rest().to_string(); 13 | let to_return = re.replace_all(pstr.as_str(), ""); 14 | 15 | if Regex::new("[0-9]{18}+") 16 | .unwrap() 17 | .is_match(to_return.to_string().as_str()) 18 | { 19 | to_return.parse::().unwrap() 20 | } else { 21 | let userid_byname = _ctx 22 | .cache 23 | .guild(*_message.guild_id.unwrap().as_u64()) 24 | .unwrap() 25 | .member_named(&to_return) 26 | .unwrap() 27 | .user 28 | .id; 29 | *userid_byname.as_u64() 30 | } 31 | 32 | // _arguments.rest().to_string().replace("<@!", "").replace(">", "") 33 | } 34 | } 35 | 36 | // pub fn avatar(_user_data: &Member) -> String { 37 | // let user = &_user_data.user; 38 | // if user.avatar_url().is_some() { 39 | // user.avatar_url().unwrap() 40 | // } else { 41 | // user.default_avatar_url() 42 | // } 43 | // } 44 | } 45 | -------------------------------------------------------------------------------- /src/utils/substr.rs: -------------------------------------------------------------------------------- 1 | use std::ops::{Bound, RangeBounds}; 2 | pub trait StringUtils { 3 | fn substring(&self, start: usize, len: usize) -> &str; 4 | fn slice(&self, range: impl RangeBounds) -> &str; 5 | } 6 | 7 | impl StringUtils for str { 8 | fn substring(&self, start: usize, len: usize) -> &str { 9 | let mut char_pos = 0; 10 | let mut byte_start = 0; 11 | let mut it = self.chars(); 12 | loop { 13 | if char_pos == start { 14 | break; 15 | } 16 | if let Some(c) = it.next() { 17 | char_pos += 1; 18 | byte_start += c.len_utf8(); 19 | } else { 20 | break; 21 | } 22 | } 23 | char_pos = 0; 24 | let mut byte_end = byte_start; 25 | loop { 26 | if char_pos == len { 27 | break; 28 | } 29 | if let Some(c) = it.next() { 30 | char_pos += 1; 31 | byte_end += c.len_utf8(); 32 | } else { 33 | break; 34 | } 35 | } 36 | &self[byte_start..byte_end] 37 | } 38 | fn slice(&self, range: impl RangeBounds) -> &str { 39 | let start = match range.start_bound() { 40 | Bound::Included(bound) | Bound::Excluded(bound) => *bound, 41 | Bound::Unbounded => 0, 42 | }; 43 | let len = match range.end_bound() { 44 | Bound::Included(bound) => *bound + 1, 45 | Bound::Excluded(bound) => *bound, 46 | Bound::Unbounded => self.len(), 47 | } - start; 48 | self.substring(start, len) 49 | } 50 | } 51 | --------------------------------------------------------------------------------