├── .cargo └── config.toml ├── .env.example ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── feature_request.yml │ └── new-command.yaml ├── renovate.json └── workflows │ ├── build.yaml │ ├── cargofmt.yaml │ ├── clippy.yaml │ └── test-on-server.yaml ├── .gitignore ├── .sqlx ├── query-0c88576dce38c07fa9d77263ab5e3e02b5fd72effb664ff96af236e7fe973374.json ├── query-0ce996dc3f45563f2bdb0d4726f2b43ccd87a52b8c69514df7897feb89c22fab.json ├── query-0fc74ffeaa8a6811f271b7905bfb0980929c0999657ba8d778d9f46f1da3613f.json ├── query-119359a3b3ee926414647c50404ecf27cf2a9ba7bf0b676f827735d1c24941fc.json ├── query-226b8494595a64c2f94760c4ce6b758da025ed0070b21f3f7ce8e1970c81ccb5.json ├── query-270180bd034c0134635b29bc20bebab94d2ba8f613a79a49eaf999c8ac805d81.json ├── query-2bdeb084c05e060d999359fc517d8d0adfee9fdebeeb9ffba3862a1f7544a318.json ├── query-3b3211af0c35f19ad8c32a048456b2adbb47775cec208ce1aebd543f38a1bfef.json ├── query-3e7804d6f6ed7af4687e5d2bab626e3adf5f552cc2d31b4ef365211f565af0ae.json ├── query-3fed338b95b7ad020b41a1f0232b17971ba254dffc0cffdfbc36821de0bd258c.json ├── query-4278ea9047d1443e8dc2c1fc64da762a219d0ec00d6e402d01463692ed8c94d5.json ├── query-5e7e1c0e6bc30fcaae882395e46cb1f3af61733077fe0525d0ff4e7096d0154a.json ├── query-600a256c4edc6bbeaf4c9f383a7c28e4cec3bad498d4e3f88e01481b188325e3.json ├── query-6aa8181feb59cd5c6dc942060b18518ebe7f37a4d07bb4b0b5fde0c21b20fb6f.json ├── query-6cb3dd3a23c32b2cec7b7710a6f8dfef4ff841c7f770bbe794c6fa41f2a8cb82.json ├── query-7193a2b848e2268d56a3dcb4b925014d951f30c1aec8f572230375a2ec9feb39.json ├── query-7577bdae53f7407534a632826a88564656e456434966a3296cc5375414697feb.json ├── query-75e00fd0d6a8ed307d638822478a085b5821478cb444fb697e4f39fa653df054.json ├── query-804dbb98d3ebdca2703d372f1e58156748f56e98d77d132251d07b52f3e90551.json ├── query-828561d33a663c1decbe23d302815b6230b02102a3549c274281899ac21d5a0a.json ├── query-8b784af44fc9e1a8edd626c6e41096a80c91d6675ad5758ecc6f59cc944b9a25.json ├── query-8cf2ddb35e421b79c2211b6b3a9feeef833e44471e11f4582d5a23432ed4743f.json ├── query-90bd03f2eb3931509feb58ef5fbf6b54075dc465976cad31cc612849d712ab65.json ├── query-9e2430ec7c7296b889637d6c19650f55374ead1e588a84651402bb5918fda54d.json ├── query-a4d40280b4cf456aa868a117a34369b17d032a2ad0b908391c7649c9e0c70d11.json ├── query-bd17229e614eafc8ec51aedac87f07a77575f9ac1882a48eb680e4c4544a8167.json ├── query-bde2664f709df5f73347676ebf4f1833307327428d791838d963898d08080c0d.json ├── query-dbe7847eb59535d213b80e8d322bd36203174963556e806b34a1e787d4814cbc.json ├── query-df84d2906bcfd74824d2e80c4582040c05113231fea91e7b0b3423673ca5a18e.json ├── query-eb70d1603c247c330ce591420fc391180193dc5d6eaf42de6c0928a38dc6ec6d.json ├── query-eb9ebcf83026a19e6038024031823ee1c7bb5ed452543dbe510b8ce4d285a630.json ├── query-f0d2ab5f212ef40d1ecec2a5424d5b861b09058f7527712ef343fc117237847c.json ├── query-f814c7bc275e84e03fd1ddc5d3a42d06c9b726426f968715d60d138af5471558.json ├── query-fc050199c34fcc2b659e3aac42252c661a3ca10c7d64fdb2c7b5195c7dc25451.json └── query-fd9087bdf617bf354cdedddc3c11992734e770ca3173f208fd54d0241163c741.json ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── README.md ├── base.db ├── crates ├── robbb │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── attachment_logging.rs │ │ ├── error_handling.rs │ │ ├── events │ │ ├── auto_moderation_action.rs │ │ ├── guild_audit_log_entry_create.rs │ │ ├── guild_member_addition.rs │ │ ├── guild_member_removal.rs │ │ ├── guild_member_update.rs │ │ ├── handle_blocklist.rs │ │ ├── message_create.rs │ │ ├── message_delete.rs │ │ ├── message_update.rs │ │ ├── mod.rs │ │ ├── reaction_add.rs │ │ ├── reaction_remove.rs │ │ └── ready.rs │ │ ├── logging.rs │ │ ├── main.rs │ │ └── re_exports.rs ├── robbb_commands │ ├── Cargo.toml │ └── src │ │ ├── checks.rs │ │ ├── commands │ │ ├── attachment_hack.rs │ │ ├── ban.rs │ │ ├── blocklist.rs │ │ ├── emojistats.rs │ │ ├── errors.rs │ │ ├── fetch │ │ │ ├── fetch.rs │ │ │ ├── mod.rs │ │ │ └── setfetch.rs │ │ ├── help.rs │ │ ├── highlights.rs │ │ ├── info.rs │ │ ├── kick.rs │ │ ├── mod.rs │ │ ├── modping.rs │ │ ├── move_users.rs │ │ ├── mute.rs │ │ ├── note.rs │ │ ├── pfp.rs │ │ ├── poll.rs │ │ ├── purge.rs │ │ ├── role.rs │ │ ├── small.rs │ │ ├── tag.rs │ │ ├── top.rs │ │ ├── unban.rs │ │ └── warn.rs │ │ ├── lib.rs │ │ └── modlog.rs ├── robbb_db │ ├── Cargo.lock │ ├── Cargo.toml │ └── src │ │ ├── db │ │ ├── blocklist.rs │ │ ├── emoji_logging.rs │ │ ├── fetch.rs │ │ ├── fetch_field.rs │ │ ├── highlights.rs │ │ ├── highlights_forbidden_words │ │ ├── htm.rs │ │ ├── mod.rs │ │ ├── mod_action.rs │ │ ├── mute.rs │ │ └── tag.rs │ │ └── lib.rs └── robbb_util │ ├── Cargo.toml │ └── src │ ├── cdn_hack.rs │ ├── collect_interaction.rs │ ├── config.rs │ ├── embeds │ ├── mod.rs │ └── paginated_embeds.rs │ ├── extensions.rs │ ├── lib.rs │ ├── prelude.rs │ └── util.rs ├── default.nix ├── fetcher.sh ├── flake.lock ├── flake.nix ├── gen-env.sh ├── migrations ├── 20220521195821_initialize.sql └── 20231120193725_Add_hard_to_moderate_table.sql ├── rust-toolchain ├── rustfmt.toml └── shell.nix /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = ["--cfg", "tokio_unstable"] 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL=sqlite:base.db 2 | TOKEN= 3 | OWNERS= 4 | GUILD= 5 | ROLE_MOD= 6 | ROLE_HELPER= 7 | ROLE_MUTE= 8 | ROLE_HTM= 9 | ROLES_COLOR= 10 | CATEGORY_MOD_PRIVATE= 11 | CATEGORY_MODMAIL= 12 | CHANNEL_ANNOUNCEMENTS= 13 | CHANNEL_RULES= 14 | CHANNEL_SHOWCASE= 15 | CHANNEL_FEEDBACK= 16 | CHANNEL_MODLOG= 17 | CHANNEL_AUTO_MOD= 18 | CHANNEL_BOT_MESSAGES= 19 | CHANNEL_MOD_BOT_STUFF= 20 | CHANNEL_BOT_TRAFFIC= 21 | CHANNEL_MOD_POLLS= 22 | CHANNEL_TECH_SUPPORT= 23 | CHANNEL_ATTACHMENT_DUMP= 24 | CHANNEL_FAKE_CDN= 25 | ATTACHMENT_CACHE_PATH=./cache 26 | ATTACHMENT_CACHE_MAX_SIZE=50000000 27 | 28 | # ROBBB_LOG_PRETTY=1 29 | 30 | PYROSCOPE_URL= 31 | PYROSCOPE_PROJECT= 32 | PYROSCOPE_USER= 33 | PYROSCOPE_PASSWORD= 34 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | /fetcher.sh @6gk 2 | /gen-env.sh @6gk 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: Report a bug you have encountered 3 | title: "[BUG] " 4 | labels: bug 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Checklist before submitting an issue 9 | options: 10 | - label: I have searched through the existing [closed and open issues](https://github.com/unixporn/robbb/issues?q=is%3Aissue) and made sure that this is not a duplicate 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: "Expected behaviour" 15 | description: "Describe what should happen if it just worked:tm:" 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: "Current Behaviour" 21 | description: "Describe what currently happens" 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: "Other info" 27 | description: "Other information that might be relevant" 28 | validations: 29 | required: false 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest an idea for this project 3 | title: "[FEATURE] " 4 | labels: enhancement 5 | body: 6 | - type: checkboxes 7 | attributes: 8 | label: Checklist before submitting an issue 9 | options: 10 | - label: I have searched through the existing [closed and open issues](https://github.com/unixporn/robbb/issues?q=is%3Aissue) and made sure that this is not a duplicate 11 | required: true 12 | - type: textarea 13 | attributes: 14 | label: "Is your feature request related to a problem? Please describe." 15 | description: "Please provide a clear description of what problem this feature would solve for you." 16 | validations: 17 | required: true 18 | - type: textarea 19 | attributes: 20 | label: "Proposed feature" 21 | description: "Describe how you would propose said problem to be solved. How should this feature look?" 22 | validations: 23 | required: true 24 | - type: textarea 25 | attributes: 26 | label: "Additional context" 27 | description: "Anything else that's relevant goes here!" 28 | validations: 29 | required: false 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/new-command.yaml: -------------------------------------------------------------------------------- 1 | 2 | name: New command 3 | description: Suggest adding a new command to the bot 4 | title: "[COMMAND] " 5 | labels: enhancement 6 | body: 7 | - type: checkboxes 8 | attributes: 9 | label: Checklist before submitting an issue 10 | options: 11 | - label: I have searched through the existing [closed and open issues](https://github.com/unixporn/robbb/issues?q=is%3Aissue) and made sure that this is not a duplicate 12 | required: true 13 | - type: input 14 | attributes: 15 | label: "Command" 16 | description: "Propose a name for the command" 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: "Description" 22 | description: "What do you want this command to do?" 23 | validations: 24 | required: true 25 | - type: textarea 26 | attributes: 27 | label: "Use-case" 28 | description: "Why do you want this command? Give examples as to how/when it could be useful to the community!" 29 | validations: 30 | required: true 31 | - type: textarea 32 | attributes: 33 | label: "Proposed syntax" 34 | description: "If possible, provide examples for how the exact command syntax could look, what arguments it should take, etc." 35 | placeholder: "!ban elk" 36 | validations: 37 | required: false 38 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": ["config:recommended"], 4 | "packageRules": [ 5 | { 6 | "groupName": "all patch updates", 7 | "groupSlug": "all-patch", 8 | "matchPackagePatterns": ["*"], 9 | "matchUpdateTypes": ["patch"] 10 | }, 11 | { 12 | "groupName": "all minor updates", 13 | "groupSlug": "all-minor", 14 | "matchPackagePatterns": ["*"], 15 | "matchUpdateTypes": ["minor"] 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build & Push 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | env: 9 | IMAGE_NAME: robbb 10 | 11 | jobs: 12 | # Push image to GitHub Packages. 13 | # See also https://docs.docker.com/docker-hub/builds/ 14 | push: 15 | runs-on: ubuntu-latest 16 | if: github.event_name == 'push' 17 | strategy: 18 | max-parallel: 1 19 | matrix: 20 | profile: 21 | - debug 22 | - release 23 | include: 24 | - profile: release 25 | cargo_flags: "--release" 26 | cargo_env: "" 27 | - profile: debug 28 | cargo_flags: "" 29 | cargo_env: "RUSTFLAGS='-C debuginfo=0'" 30 | 31 | steps: 32 | - uses: actions/checkout@v4 33 | 34 | - name: Rust version 35 | id: rust-version 36 | run: | 37 | echo "::set-output name=rust_version::$(rustc --version)" 38 | 39 | - uses: actions/cache@v4 40 | with: 41 | path: | 42 | ~/.cargo/registry 43 | ~/.cargo/git 44 | target 45 | key: ${{ matrix.profile }} ${{ runner.os }} ${{ steps.rust-version.outputs.rust_version }} ${{ hashFiles('Cargo.lock') }} ${{ hashFiles('src/**') }} 46 | restore-keys: | 47 | ${{ matrix.profile }} ${{ runner.os }} ${{ steps.rust-version.outputs.rust_version }} ${{ hashFiles('Cargo.lock') }} 48 | 49 | - name: Log into registry 50 | run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin 51 | 52 | - name: Build and push image 53 | env: 54 | DOCKER_BUILDKIT: 1 55 | run: | 56 | IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME:${{ github.sha }}-${{ matrix.profile }} 57 | 58 | # Change all uppercase to lowercase 59 | IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]') 60 | 61 | echo IMAGE_ID=$IMAGE_ID 62 | 63 | mkdir artifacts 64 | 65 | # prebuild docker image 66 | touch artifacts/robbb 67 | docker build -f Dockerfile artifacts >/dev/null 2>/dev/null & 68 | prebuild_pid=$! 69 | 70 | ROBBB_PROFILE=${{ matrix.profile }} \ 71 | ROBBB_COMMIT_HASH="$(git log --format='%H' -n 1 HEAD)" \ 72 | ROBBB_COMMIT_MSG="$(git log --format='%s' -n 1 HEAD)" \ 73 | ${{ matrix.cargo_env }} \ 74 | cargo build ${{ matrix.cargo_flags }} --locked & 75 | cargo_pid=$! 76 | 77 | wait $prebuild_pid $cargo_pid 78 | 79 | cp target/${{ matrix.profile }}/robbb artifacts/ 80 | docker build -t $IMAGE_ID -f Dockerfile artifacts/ 81 | docker push $IMAGE_ID 82 | -------------------------------------------------------------------------------- /.github/workflows/cargofmt.yaml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: cargo fmt 4 | 5 | jobs: 6 | cargofmt: 7 | runs-on: ubuntu-latest 8 | if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository 9 | steps: 10 | - uses: actions/checkout@v4 11 | - run: cargo fmt -- --check 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/clippy.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | 7 | name: clippy 8 | 9 | jobs: 10 | clippy: 11 | runs-on: ubuntu-latest 12 | if: github.event_name != 'push' || github.event.pull_request.head.repo.full_name != github.repository 13 | steps: 14 | - uses: actions/checkout@v4 15 | - run: rustup component add clippy 16 | - uses: Swatinem/rust-cache@v2 17 | - run: cargo clippy -- -D warnings 18 | -------------------------------------------------------------------------------- /.github/workflows/test-on-server.yaml: -------------------------------------------------------------------------------- 1 | name: Test on server 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | git_clone_arg: 7 | description: "git clone (example: https://github.com/unixporn/robbb.git)" 8 | required: true 9 | git_tag: 10 | description: "git tag (example: master or commit like 097803edee03e25b086e1a674c3091a458e0da9f)" 11 | required: true 12 | environment_vars: 13 | description: "Environment variables for the bot (example: CHANNEL_SAY_HI=blabla CHANNEL_RULES=blabla)" 14 | required: false 15 | 16 | jobs: 17 | build: 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Clone target repository 21 | run: | 22 | git clone "${{github.event.inputs.git_clone_arg}}" . 23 | git checkout "${{github.event.inputs.git_tag}}" 24 | 25 | - name: Install rust stable 26 | uses: actions-rs/toolchain@v1 27 | 28 | - uses: Swatinem/rust-cache@v2 29 | with: 30 | key: test-on-server 31 | 32 | - name: Build 33 | run: | 34 | VERSION="${{github.event.inputs.git_clone_arg}}#${{github.event.inputs.git_tag}} $(git log --format=oneline -n 1 HEAD)" cargo build --locked 35 | 36 | - uses: actions/upload-artifact@v4 37 | with: 38 | name: executable 39 | path: target/debug/robbb 40 | 41 | run: 42 | needs: build 43 | runs-on: ubuntu-latest 44 | environment: test 45 | steps: 46 | - name: Checkout our repository 47 | uses: actions/checkout@v4 48 | 49 | - run: sudo apt-get install -y jq 50 | 51 | - name: Generate environment variables 52 | run: | 53 | echo -e "${{secrets.GUILD}}\n${{secrets.TOKEN}}\n" | ./gen-env.sh >.env 54 | 55 | - uses: actions/download-artifact@v4 56 | with: 57 | name: executable 58 | 59 | - name: Start the bot 60 | run: | 61 | export $(cat .env) ${{github.event.inputs.environment_vars}} && chmod +x ./robbb 62 | mkdir $ATTACHMENT_CACHE_PATH 63 | timeout $((60 * 60)) ./robbb || exit 0 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .env 3 | .envrc 4 | flake.lock 5 | trup.db-* 6 | trup.db 7 | robbb.db 8 | robbb.db-* 9 | cache 10 | base.db-shm 11 | base.db-wal 12 | result 13 | local.db 14 | local.db-* 15 | -------------------------------------------------------------------------------- /.sqlx/query-0c88576dce38c07fa9d77263ab5e3e02b5fd72effb664ff96af236e7fe973374.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into emoji_stats (emoji_id, emoji_name, reaction_usage, animated) values (?1, ?2, max(0, ?3), ?4) on conflict(emoji_id) do update set reaction_usage=max(0, reaction_usage + ?3)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 4 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "0c88576dce38c07fa9d77263ab5e3e02b5fd72effb664ff96af236e7fe973374" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-0ce996dc3f45563f2bdb0d4726f2b43ccd87a52b8c69514df7897feb89c22fab.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "delete from mod_action where id=? AND usr=?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "0ce996dc3f45563f2bdb0d4726f2b43ccd87a52b8c69514df7897feb89c22fab" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-0fc74ffeaa8a6811f271b7905bfb0980929c0999657ba8d778d9f46f1da3613f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "SELECT * from mute, mod_action\n WHERE mute.mod_action = mod_action.id\n AND cast(strftime('%s', end_time) as integer) < cast(strftime('%s', datetime('now')) as integer)\n AND active", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "mod_action", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "end_time", 13 | "ordinal": 1, 14 | "type_info": "Datetime" 15 | }, 16 | { 17 | "name": "active", 18 | "ordinal": 2, 19 | "type_info": "Bool" 20 | }, 21 | { 22 | "name": "id", 23 | "ordinal": 3, 24 | "type_info": "Int64" 25 | }, 26 | { 27 | "name": "moderator", 28 | "ordinal": 4, 29 | "type_info": "Int64" 30 | }, 31 | { 32 | "name": "usr", 33 | "ordinal": 5, 34 | "type_info": "Int64" 35 | }, 36 | { 37 | "name": "reason", 38 | "ordinal": 6, 39 | "type_info": "Text" 40 | }, 41 | { 42 | "name": "context", 43 | "ordinal": 7, 44 | "type_info": "Text" 45 | }, 46 | { 47 | "name": "action_type", 48 | "ordinal": 8, 49 | "type_info": "Int64" 50 | }, 51 | { 52 | "name": "create_date", 53 | "ordinal": 9, 54 | "type_info": "Datetime" 55 | } 56 | ], 57 | "parameters": { 58 | "Right": 0 59 | }, 60 | "nullable": [ 61 | false, 62 | false, 63 | false, 64 | false, 65 | false, 66 | false, 67 | true, 68 | true, 69 | false, 70 | true 71 | ] 72 | }, 73 | "hash": "0fc74ffeaa8a6811f271b7905bfb0980929c0999657ba8d778d9f46f1da3613f" 74 | } 75 | -------------------------------------------------------------------------------- /.sqlx/query-119359a3b3ee926414647c50404ecf27cf2a9ba7bf0b676f827735d1c24941fc.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from emoji_stats where emoji_name=?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "emoji_id", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "emoji_name", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "animated", 18 | "ordinal": 2, 19 | "type_info": "Int64" 20 | }, 21 | { 22 | "name": "in_text_usage", 23 | "ordinal": 3, 24 | "type_info": "Int64" 25 | }, 26 | { 27 | "name": "reaction_usage", 28 | "ordinal": 4, 29 | "type_info": "Int64" 30 | } 31 | ], 32 | "parameters": { 33 | "Right": 1 34 | }, 35 | "nullable": [ 36 | false, 37 | true, 38 | false, 39 | false, 40 | false 41 | ] 42 | }, 43 | "hash": "119359a3b3ee926414647c50404ecf27cf2a9ba7bf0b676f827735d1c24941fc" 44 | } 45 | -------------------------------------------------------------------------------- /.sqlx/query-226b8494595a64c2f94760c4ce6b758da025ed0070b21f3f7ce8e1970c81ccb5.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from emoji_stats where emoji_id=?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "emoji_id", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "emoji_name", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "animated", 18 | "ordinal": 2, 19 | "type_info": "Int64" 20 | }, 21 | { 22 | "name": "in_text_usage", 23 | "ordinal": 3, 24 | "type_info": "Int64" 25 | }, 26 | { 27 | "name": "reaction_usage", 28 | "ordinal": 4, 29 | "type_info": "Int64" 30 | } 31 | ], 32 | "parameters": { 33 | "Right": 1 34 | }, 35 | "nullable": [ 36 | false, 37 | true, 38 | false, 39 | false, 40 | false 41 | ] 42 | }, 43 | "hash": "226b8494595a64c2f94760c4ce6b758da025ed0070b21f3f7ce8e1970c81ccb5" 44 | } 45 | -------------------------------------------------------------------------------- /.sqlx/query-270180bd034c0134635b29bc20bebab94d2ba8f613a79a49eaf999c8ac805d81.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select pattern as \"pattern!\" from blocked_regexes", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "pattern!", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 0 14 | }, 15 | "nullable": [ 16 | true 17 | ] 18 | }, 19 | "hash": "270180bd034c0134635b29bc20bebab94d2ba8f613a79a49eaf999c8ac805d81" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-2bdeb084c05e060d999359fc517d8d0adfee9fdebeeb9ffba3862a1f7544a318.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "delete from highlights where usr=?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "2bdeb084c05e060d999359fc517d8d0adfee9fdebeeb9ffba3862a1f7544a318" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-3b3211af0c35f19ad8c32a048456b2adbb47775cec208ce1aebd543f38a1bfef.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from hard_to_moderate where usr=?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "usr", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 1 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "3b3211af0c35f19ad8c32a048456b2adbb47775cec208ce1aebd543f38a1bfef" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-3e7804d6f6ed7af4687e5d2bab626e3adf5f552cc2d31b4ef365211f565af0ae.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from mute, mod_action where mute.mod_action = mod_action.id AND usr=? AND active=true", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "mod_action", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "end_time", 13 | "ordinal": 1, 14 | "type_info": "Datetime" 15 | }, 16 | { 17 | "name": "active", 18 | "ordinal": 2, 19 | "type_info": "Bool" 20 | }, 21 | { 22 | "name": "id", 23 | "ordinal": 3, 24 | "type_info": "Int64" 25 | }, 26 | { 27 | "name": "moderator", 28 | "ordinal": 4, 29 | "type_info": "Int64" 30 | }, 31 | { 32 | "name": "usr", 33 | "ordinal": 5, 34 | "type_info": "Int64" 35 | }, 36 | { 37 | "name": "reason", 38 | "ordinal": 6, 39 | "type_info": "Text" 40 | }, 41 | { 42 | "name": "context", 43 | "ordinal": 7, 44 | "type_info": "Text" 45 | }, 46 | { 47 | "name": "action_type", 48 | "ordinal": 8, 49 | "type_info": "Int64" 50 | }, 51 | { 52 | "name": "create_date", 53 | "ordinal": 9, 54 | "type_info": "Datetime" 55 | } 56 | ], 57 | "parameters": { 58 | "Right": 1 59 | }, 60 | "nullable": [ 61 | false, 62 | false, 63 | false, 64 | false, 65 | false, 66 | false, 67 | true, 68 | true, 69 | false, 70 | true 71 | ] 72 | }, 73 | "hash": "3e7804d6f6ed7af4687e5d2bab626e3adf5f552cc2d31b4ef365211f565af0ae" 74 | } 75 | -------------------------------------------------------------------------------- /.sqlx/query-3fed338b95b7ad020b41a1f0232b17971ba254dffc0cffdfbc36821de0bd258c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "SELECT COUNT(*) FROM mod_action WHERE usr=? AND action_type=?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "COUNT(*)", 8 | "ordinal": 0, 9 | "type_info": "Int" 10 | } 11 | ], 12 | "parameters": { 13 | "Right": 2 14 | }, 15 | "nullable": [ 16 | false 17 | ] 18 | }, 19 | "hash": "3fed338b95b7ad020b41a1f0232b17971ba254dffc0cffdfbc36821de0bd258c" 20 | } 21 | -------------------------------------------------------------------------------- /.sqlx/query-4278ea9047d1443e8dc2c1fc64da762a219d0ec00d6e402d01463692ed8c94d5.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select *, in_text_usage + reaction_usage as \"usage!: i32\" FROM emoji_stats order by \"usage!: i32\" ASC limit ?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "emoji_id", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "emoji_name", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "animated", 18 | "ordinal": 2, 19 | "type_info": "Int64" 20 | }, 21 | { 22 | "name": "in_text_usage", 23 | "ordinal": 3, 24 | "type_info": "Int64" 25 | }, 26 | { 27 | "name": "reaction_usage", 28 | "ordinal": 4, 29 | "type_info": "Int64" 30 | }, 31 | { 32 | "name": "usage!: i32", 33 | "ordinal": 5, 34 | "type_info": "Int64" 35 | } 36 | ], 37 | "parameters": { 38 | "Right": 1 39 | }, 40 | "nullable": [ 41 | false, 42 | true, 43 | false, 44 | false, 45 | false, 46 | false 47 | ] 48 | }, 49 | "hash": "4278ea9047d1443e8dc2c1fc64da762a219d0ec00d6e402d01463692ed8c94d5" 50 | } 51 | -------------------------------------------------------------------------------- /.sqlx/query-5e7e1c0e6bc30fcaae882395e46cb1f3af61733077fe0525d0ff4e7096d0154a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into tag (name, moderator, content, official, create_date) values (?, ?, ?, ?, ?)\n on conflict(name) do update set moderator=?, content=?, official=?, create_date=?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 9 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "5e7e1c0e6bc30fcaae882395e46cb1f3af61733077fe0525d0ff4e7096d0154a" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-600a256c4edc6bbeaf4c9f383a7c28e4cec3bad498d4e3f88e01481b188325e3.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "delete from highlights where word=? and usr=?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "600a256c4edc6bbeaf4c9f383a7c28e4cec3bad498d4e3f88e01481b188325e3" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-6aa8181feb59cd5c6dc942060b18518ebe7f37a4d07bb4b0b5fde0c21b20fb6f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into mute (mod_action, end_time, active) VALUES(?, ?, ?)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 3 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "6aa8181feb59cd5c6dc942060b18518ebe7f37a4d07bb4b0b5fde0c21b20fb6f" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-6cb3dd3a23c32b2cec7b7710a6f8dfef4ff841c7f770bbe794c6fa41f2a8cb82.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "update mute set active=false\n from mute m\n join mod_action on mod_action.id = m.mod_action\n where mod_action.usr=? and m.active=true\n ", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "6cb3dd3a23c32b2cec7b7710a6f8dfef4ff841c7f770bbe794c6fa41f2a8cb82" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-7193a2b848e2268d56a3dcb4b925014d951f30c1aec8f572230375a2ec9feb39.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from fetch", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "usr", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "info", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "create_date", 18 | "ordinal": 2, 19 | "type_info": "Datetime" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 0 24 | }, 25 | "nullable": [ 26 | false, 27 | false, 28 | true 29 | ] 30 | }, 31 | "hash": "7193a2b848e2268d56a3dcb4b925014d951f30c1aec8f572230375a2ec9feb39" 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-7577bdae53f7407534a632826a88564656e456434966a3296cc5375414697feb.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert or ignore into hard_to_moderate (usr) values (?)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "7577bdae53f7407534a632826a88564656e456434966a3296cc5375414697feb" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-75e00fd0d6a8ed307d638822478a085b5821478cb444fb697e4f39fa653df054.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from mute, mod_action where mute.mod_action = mod_action.id AND usr=?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "mod_action", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "end_time", 13 | "ordinal": 1, 14 | "type_info": "Datetime" 15 | }, 16 | { 17 | "name": "active", 18 | "ordinal": 2, 19 | "type_info": "Bool" 20 | }, 21 | { 22 | "name": "id", 23 | "ordinal": 3, 24 | "type_info": "Int64" 25 | }, 26 | { 27 | "name": "moderator", 28 | "ordinal": 4, 29 | "type_info": "Int64" 30 | }, 31 | { 32 | "name": "usr", 33 | "ordinal": 5, 34 | "type_info": "Int64" 35 | }, 36 | { 37 | "name": "reason", 38 | "ordinal": 6, 39 | "type_info": "Text" 40 | }, 41 | { 42 | "name": "context", 43 | "ordinal": 7, 44 | "type_info": "Text" 45 | }, 46 | { 47 | "name": "action_type", 48 | "ordinal": 8, 49 | "type_info": "Int64" 50 | }, 51 | { 52 | "name": "create_date", 53 | "ordinal": 9, 54 | "type_info": "Datetime" 55 | } 56 | ], 57 | "parameters": { 58 | "Right": 1 59 | }, 60 | "nullable": [ 61 | false, 62 | false, 63 | false, 64 | false, 65 | false, 66 | false, 67 | true, 68 | true, 69 | false, 70 | true 71 | ] 72 | }, 73 | "hash": "75e00fd0d6a8ed307d638822478a085b5821478cb444fb697e4f39fa653df054" 74 | } 75 | -------------------------------------------------------------------------------- /.sqlx/query-804dbb98d3ebdca2703d372f1e58156748f56e98d77d132251d07b52f3e90551.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "update mod_action set reason=?, moderator=? where id=?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 3 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "804dbb98d3ebdca2703d372f1e58156748f56e98d77d132251d07b52f3e90551" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-828561d33a663c1decbe23d302815b6230b02102a3549c274281899ac21d5a0a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\n SELECT * FROM mod_action\n LEFT JOIN mute ON mod_action.id = mute.mod_action\n WHERE id=?1\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "moderator", 13 | "ordinal": 1, 14 | "type_info": "Int64" 15 | }, 16 | { 17 | "name": "usr", 18 | "ordinal": 2, 19 | "type_info": "Int64" 20 | }, 21 | { 22 | "name": "reason", 23 | "ordinal": 3, 24 | "type_info": "Text" 25 | }, 26 | { 27 | "name": "context", 28 | "ordinal": 4, 29 | "type_info": "Text" 30 | }, 31 | { 32 | "name": "action_type", 33 | "ordinal": 5, 34 | "type_info": "Int64" 35 | }, 36 | { 37 | "name": "create_date", 38 | "ordinal": 6, 39 | "type_info": "Datetime" 40 | }, 41 | { 42 | "name": "mod_action", 43 | "ordinal": 7, 44 | "type_info": "Int64" 45 | }, 46 | { 47 | "name": "end_time", 48 | "ordinal": 8, 49 | "type_info": "Datetime" 50 | }, 51 | { 52 | "name": "active", 53 | "ordinal": 9, 54 | "type_info": "Bool" 55 | } 56 | ], 57 | "parameters": { 58 | "Right": 1 59 | }, 60 | "nullable": [ 61 | false, 62 | false, 63 | false, 64 | true, 65 | true, 66 | false, 67 | true, 68 | true, 69 | true, 70 | true 71 | ] 72 | }, 73 | "hash": "828561d33a663c1decbe23d302815b6230b02102a3549c274281899ac21d5a0a" 74 | } 75 | -------------------------------------------------------------------------------- /.sqlx/query-8b784af44fc9e1a8edd626c6e41096a80c91d6675ad5758ecc6f59cc944b9a25.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into mod_action (moderator, usr, reason, create_date, context, action_type) values(?, ?, ?, ?, ?, ?)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 6 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "8b784af44fc9e1a8edd626c6e41096a80c91d6675ad5758ecc6f59cc944b9a25" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-8cf2ddb35e421b79c2211b6b3a9feeef833e44471e11f4582d5a23432ed4743f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select name as \"name!\", moderator, content, official, create_date from tag where name=? COLLATE NOCASE", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "name!", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "moderator", 13 | "ordinal": 1, 14 | "type_info": "Int64" 15 | }, 16 | { 17 | "name": "content", 18 | "ordinal": 2, 19 | "type_info": "Text" 20 | }, 21 | { 22 | "name": "official", 23 | "ordinal": 3, 24 | "type_info": "Bool" 25 | }, 26 | { 27 | "name": "create_date", 28 | "ordinal": 4, 29 | "type_info": "Datetime" 30 | } 31 | ], 32 | "parameters": { 33 | "Right": 1 34 | }, 35 | "nullable": [ 36 | true, 37 | false, 38 | false, 39 | false, 40 | true 41 | ] 42 | }, 43 | "hash": "8cf2ddb35e421b79c2211b6b3a9feeef833e44471e11f4582d5a23432ed4743f" 44 | } 45 | -------------------------------------------------------------------------------- /.sqlx/query-90bd03f2eb3931509feb58ef5fbf6b54075dc465976cad31cc612849d712ab65.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into emoji_stats (emoji_id, emoji_name, in_text_usage, animated) values (?1, ?2, max(0, ?3), ?4) on conflict(emoji_id) do update set in_text_usage=max(0, in_text_usage + ?3)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 4 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "90bd03f2eb3931509feb58ef5fbf6b54075dc465976cad31cc612849d712ab65" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-9e2430ec7c7296b889637d6c19650f55374ead1e588a84651402bb5918fda54d.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into highlights (word, usr) values (?, ?)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "9e2430ec7c7296b889637d6c19650f55374ead1e588a84651402bb5918fda54d" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-a4d40280b4cf456aa868a117a34369b17d032a2ad0b908391c7649c9e0c70d11.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into fetch (usr, info, create_date) values (?1, ?2, ?3) on conflict(usr) do update set info=?2, create_date=?3", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 3 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "a4d40280b4cf456aa868a117a34369b17d032a2ad0b908391c7649c9e0c70d11" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-bd17229e614eafc8ec51aedac87f07a77575f9ac1882a48eb680e4c4544a8167.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select *, in_text_usage + reaction_usage as \"usage!: i32\" FROM emoji_stats order by \"usage!: i32\" DESC limit ?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "emoji_id", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "emoji_name", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "animated", 18 | "ordinal": 2, 19 | "type_info": "Int64" 20 | }, 21 | { 22 | "name": "in_text_usage", 23 | "ordinal": 3, 24 | "type_info": "Int64" 25 | }, 26 | { 27 | "name": "reaction_usage", 28 | "ordinal": 4, 29 | "type_info": "Int64" 30 | }, 31 | { 32 | "name": "usage!: i32", 33 | "ordinal": 5, 34 | "type_info": "Int64" 35 | } 36 | ], 37 | "parameters": { 38 | "Right": 1 39 | }, 40 | "nullable": [ 41 | false, 42 | true, 43 | false, 44 | false, 45 | false, 46 | false 47 | ] 48 | }, 49 | "hash": "bd17229e614eafc8ec51aedac87f07a77575f9ac1882a48eb680e4c4544a8167" 50 | } 51 | -------------------------------------------------------------------------------- /.sqlx/query-bde2664f709df5f73347676ebf4f1833307327428d791838d963898d08080c0d.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "delete from blocked_regexes where pattern=?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "bde2664f709df5f73347676ebf4f1833307327428d791838d963898d08080c0d" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-dbe7847eb59535d213b80e8d322bd36203174963556e806b34a1e787d4814cbc.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "insert into blocked_regexes(pattern, added_by) values (?, ?)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 2 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "dbe7847eb59535d213b80e8d322bd36203174963556e806b34a1e787d4814cbc" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-df84d2906bcfd74824d2e80c4582040c05113231fea91e7b0b3423673ca5a18e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from highlights", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "word", 8 | "ordinal": 0, 9 | "type_info": "Text" 10 | }, 11 | { 12 | "name": "usr", 13 | "ordinal": 1, 14 | "type_info": "Int64" 15 | } 16 | ], 17 | "parameters": { 18 | "Right": 0 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "df84d2906bcfd74824d2e80c4582040c05113231fea91e7b0b3423673ca5a18e" 26 | } 27 | -------------------------------------------------------------------------------- /.sqlx/query-eb70d1603c247c330ce591420fc391180193dc5d6eaf42de6c0928a38dc6ec6d.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "select * from fetch where usr=?", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "usr", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "info", 13 | "ordinal": 1, 14 | "type_info": "Text" 15 | }, 16 | { 17 | "name": "create_date", 18 | "ordinal": 2, 19 | "type_info": "Datetime" 20 | } 21 | ], 22 | "parameters": { 23 | "Right": 1 24 | }, 25 | "nullable": [ 26 | false, 27 | false, 28 | true 29 | ] 30 | }, 31 | "hash": "eb70d1603c247c330ce591420fc391180193dc5d6eaf42de6c0928a38dc6ec6d" 32 | } 33 | -------------------------------------------------------------------------------- /.sqlx/query-eb9ebcf83026a19e6038024031823ee1c7bb5ed452543dbe510b8ce4d285a630.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "SELECT action_type, COUNT(*) as \"count!: i32\" FROM mod_action WHERE usr=? GROUP BY action_type", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "action_type", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "count!: i32", 13 | "ordinal": 1, 14 | "type_info": "Int64" 15 | } 16 | ], 17 | "parameters": { 18 | "Right": 1 19 | }, 20 | "nullable": [ 21 | false, 22 | false 23 | ] 24 | }, 25 | "hash": "eb9ebcf83026a19e6038024031823ee1c7bb5ed452543dbe510b8ce4d285a630" 26 | } 27 | -------------------------------------------------------------------------------- /.sqlx/query-f0d2ab5f212ef40d1ecec2a5424d5b861b09058f7527712ef343fc117237847c.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "delete from hard_to_moderate where usr=?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "f0d2ab5f212ef40d1ecec2a5424d5b861b09058f7527712ef343fc117237847c" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-f814c7bc275e84e03fd1ddc5d3a42d06c9b726426f968715d60d138af5471558.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "\n SELECT * FROM mod_action\n LEFT JOIN mute ON mod_action.id = mute.mod_action\n WHERE usr=?1 AND (?2 IS NULL OR action_type=?2)\n ", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "name": "id", 8 | "ordinal": 0, 9 | "type_info": "Int64" 10 | }, 11 | { 12 | "name": "moderator", 13 | "ordinal": 1, 14 | "type_info": "Int64" 15 | }, 16 | { 17 | "name": "usr", 18 | "ordinal": 2, 19 | "type_info": "Int64" 20 | }, 21 | { 22 | "name": "reason", 23 | "ordinal": 3, 24 | "type_info": "Text" 25 | }, 26 | { 27 | "name": "context", 28 | "ordinal": 4, 29 | "type_info": "Text" 30 | }, 31 | { 32 | "name": "action_type", 33 | "ordinal": 5, 34 | "type_info": "Int64" 35 | }, 36 | { 37 | "name": "create_date", 38 | "ordinal": 6, 39 | "type_info": "Datetime" 40 | }, 41 | { 42 | "name": "mod_action", 43 | "ordinal": 7, 44 | "type_info": "Int64" 45 | }, 46 | { 47 | "name": "end_time", 48 | "ordinal": 8, 49 | "type_info": "Datetime" 50 | }, 51 | { 52 | "name": "active", 53 | "ordinal": 9, 54 | "type_info": "Bool" 55 | } 56 | ], 57 | "parameters": { 58 | "Right": 2 59 | }, 60 | "nullable": [ 61 | false, 62 | false, 63 | false, 64 | true, 65 | true, 66 | false, 67 | true, 68 | true, 69 | true, 70 | true 71 | ] 72 | }, 73 | "hash": "f814c7bc275e84e03fd1ddc5d3a42d06c9b726426f968715d60d138af5471558" 74 | } 75 | -------------------------------------------------------------------------------- /.sqlx/query-fc050199c34fcc2b659e3aac42252c661a3ca10c7d64fdb2c7b5195c7dc25451.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "update mute set active = false where mod_action = ?", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "fc050199c34fcc2b659e3aac42252c661a3ca10c7d64fdb2c7b5195c7dc25451" 12 | } 13 | -------------------------------------------------------------------------------- /.sqlx/query-fd9087bdf617bf354cdedddc3c11992734e770ca3173f208fd54d0241163c741.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "SQLite", 3 | "query": "delete from tag where name=? COLLATE NOCASE", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Right": 1 8 | }, 9 | "nullable": [] 10 | }, 11 | "hash": "fd9087bdf617bf354cdedddc3c11992734e770ca3173f208fd54d0241163c741" 12 | } 13 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "crates/robbb", 4 | "crates/robbb_db", 5 | "crates/robbb_commands", 6 | "crates/robbb_util", 7 | ] 8 | resolver = "2" 9 | 10 | 11 | [workspace.dependencies] 12 | serenity = { version = "0.12.1", default-features = false, features = [ 13 | "collector", 14 | "builder", 15 | "cache", 16 | "chrono", 17 | "client", 18 | "gateway", 19 | "model", 20 | "http", 21 | "utils", 22 | "rustls_backend", 23 | "temp_cache", 24 | "tokio_task_builder", 25 | ] } 26 | poise = "0.6.1" 27 | 28 | 29 | [profile.dev] 30 | split-debuginfo = "unpacked" 31 | 32 | [profile.release] 33 | debug = true 34 | 35 | 36 | [patch.crates-io] 37 | #poise = { git = "https://github.com/kangalioo/poise", rev = "0f2eb876397d1712d38432adc759ca3b9186d7ff" } 38 | #serenity = { git = "https://github.com/bumblepie/serenity", rev = "1fba7ba6bcf0a9fd4f645c265b42fe9bf8c45bc4" } 39 | #serenity = { git = "https://github.com/serenity-rs/serenity", rev = "5363f2a8a362dc9bc210c9a87da985d43ab7faca" } 40 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim 2 | 3 | RUN apt-get -y update && apt-get -y install ca-certificates wget \ 4 | gdb heaptrack \ 5 | && rm -rf /var/lib/apt/lists/* 6 | 7 | RUN wget https://github.com/koute/bytehound/releases/download/0.11.0/bytehound-x86_64-unknown-linux-gnu.tgz \ 8 | && tar -xvzf bytehound-x86_64-unknown-linux-gnu.tgz 9 | 10 | COPY ./robbb /usr/local/bin/robbb 11 | RUN chmod +x /usr/local/bin/robbb 12 | 13 | # ENV LD_PRELOAD=./libbytehound.so 14 | CMD ["robbb"] 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 ElKowar 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Say hi to Robbb! 2 | 3 | **A Discord bot for the Unixporn community** 4 | 5 | Now written in a _good_ language! 6 | 7 | 8 | ## Dependencies 9 | 10 | - Rust 11 | - sqlx-cli (if you need to change the database schema) 12 | 13 | ## Set up environment variables 14 | 15 | The bot reads the data it needs from environment variables. 16 | To see which values have to be set, check out the provided [.env.example](./.env.example) file. 17 | you can use `export $(cat .env)` to export the variables from a .env file in your current environment. 18 | 19 | ### Extra information 20 | 21 | Most environment variables are retrieved by right clicking, and copying the ID of the relevant channel, category, role. 22 | You need to have developer mode turned on for that to be possible. 23 | 24 | - TOKEN: The discord bot token, retrieved from: https://discord.com/developers/applications 25 | - GUILD: The ID of the guild, where the host resides 26 | - ROLE\_\*: IDs of relevant roles, easily copied from Server Settings -> Roles. 27 | - ROLE\_COLOR: Unlike other ROLE variables, this is a comma (`,`) separated list, ex.: `ROLES_COLOR=825158129711972372,635627141123538966` 28 | - CHANNEL\_\*: Channel IDs, based on which the bot performs moderation or responses 29 | - ATTACHMENT\_CACHE\_\*: Location (directory) and size of local message attachments cache (in case they get deleted) 30 | 31 | Additionally, you can use [this script](gen-env.sh) to generate the role & channel variables from [a template server](https://discord.new/zkhTrUTEbtg9) 32 | 33 | 34 | ## Database 35 | 36 | The bot uses a SQLite database, which does not have to be started externally. 37 | The included sqlite-db file is not the actual database used in production, but just an empty database used for development. 38 | To change and work with the database, use [sqlx-cli](https://github.com/launchbadge/sqlx/tree/master/sqlx-cli) to add migrations and generate a new, updated database file. 39 | -------------------------------------------------------------------------------- /base.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixporn/robbb/9af1e242d7ca593aa19ae3bbf19cd71f390d9c61/base.db -------------------------------------------------------------------------------- /crates/robbb/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "robbb" 3 | version = "0.1.0" 4 | authors = ["elkowar <5300871+elkowar@users.noreply.github.com>"] 5 | edition = "2018" 6 | 7 | 8 | [dependencies] 9 | serenity.workspace = true 10 | poise.workspace = true 11 | anyhow = "1.0.82" 12 | chrono = "0.4.38" 13 | chrono-humanize = "0.2.3" 14 | itertools = "0.12.1" 15 | indoc = "2.0.5" 16 | lazy_static = "1.4" 17 | rand = "0.8.5" 18 | humantime = "2.1.0" 19 | thiserror = "1.0.59" 20 | serde_json = "1.0.116" 21 | serde = "1.0.200" 22 | maplit = "1.0.2" 23 | byte-unit = { version = "5.1.4", features = ["u128"] } 24 | url = "2" 25 | regex = "1" 26 | reqwest = { version = "0.11" } 27 | tokio = { version = "1.21", features = ["macros", "fs", "rt-multi-thread"] } 28 | tokio-util = { version = "0.7.10", features = ["compat"] } 29 | futures = "0.3.30" 30 | 31 | unicase = "2.6.0" 32 | 33 | parking_lot = "0.12.2" 34 | 35 | tracing = "0.1.40" 36 | tracing-log = "0.2.0" 37 | tracing-futures = "0.2.5" 38 | tracing-subscriber = { version = "0.3.18", features = [ 39 | "std", 40 | "env-filter", 41 | "tracing-log", 42 | ] } 43 | 44 | 45 | robbb_db = { path = "../robbb_db" } 46 | robbb_util = { path = "../robbb_util" } 47 | robbb_commands = { path = "../robbb_commands" } 48 | # tracing-logfmt = "0.3.3" 49 | tracing-logfmt-otel = { version = "0.2.0" } 50 | 51 | opentelemetry = { version = "0.21.0", features = ["trace", "logs"] } 52 | opentelemetry-otlp = { version = "0.14.0", features = [ 53 | "http-proto", 54 | "reqwest-client", 55 | "grpc-tonic", 56 | ] } 57 | opentelemetry_sdk = { version = "0.21.2", features = ["rt-tokio"] } 58 | tracing-opentelemetry = "0.22.0" 59 | 60 | 61 | pyroscope = "0.5.7" 62 | pyroscope_pprofrs = "0.2.7" 63 | -------------------------------------------------------------------------------- /crates/robbb/src/attachment_logging.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use robbb_util::config::Config; 3 | use std::{ 4 | os::unix::prelude::MetadataExt, 5 | path::{Path, PathBuf}, 6 | }; 7 | use tokio_util::compat::FuturesAsyncReadCompatExt; 8 | use tracing_futures::Instrument; 9 | 10 | use serenity::{ 11 | futures::{self, future::try_join_all, TryStreamExt}, 12 | model::{ 13 | channel::Attachment, 14 | id::{ChannelId, MessageId}, 15 | }, 16 | }; 17 | 18 | #[tracing::instrument(skip_all, fields(msg.id = %msg_id, msg.channel_id = %channel_id))] 19 | pub async fn store_attachments( 20 | attachments: impl IntoIterator, 21 | msg_id: MessageId, 22 | channel_id: ChannelId, 23 | attachment_cache_path: PathBuf, 24 | ) -> Result<()> { 25 | let dirname = generate_dirname(channel_id, msg_id); 26 | let attachment_dir_path = attachment_cache_path.join(dirname); 27 | tokio::fs::create_dir_all(&attachment_dir_path).await?; 28 | 29 | try_join_all( 30 | attachments 31 | .into_iter() 32 | .map(|attachment| store_single_attachment(attachment_dir_path.clone(), attachment)), 33 | ) 34 | .await?; 35 | 36 | Ok(()) 37 | } 38 | 39 | #[tracing::instrument(skip_all, fields(%attachment.url, %attachment.size, %attachment.filename, ?attachment.content_type))] 40 | /// Store a single attachment in the given directory path. 41 | async fn store_single_attachment(dir_path: impl AsRef, attachment: Attachment) -> Result<()> { 42 | let file_path = dir_path.as_ref().join(attachment.filename); 43 | tracing::debug!(file_path = %file_path.display(), "Storing file {}", &file_path.display()); 44 | 45 | let resp = reqwest::get(&attachment.url).await.context("Failed to load attachment")?; 46 | let mut body = resp 47 | .bytes_stream() 48 | .map_err(|e| futures::io::Error::new(futures::io::ErrorKind::Other, e)) 49 | .into_async_read() 50 | .compat(); 51 | 52 | let mut attachment_file = 53 | tokio::fs::File::create(file_path).await.context("Failed to create attachment log file")?; 54 | 55 | tokio::io::copy(&mut body, &mut attachment_file).await?; 56 | Ok(()) 57 | } 58 | 59 | #[tracing::instrument(skip_all)] 60 | /// Search for logged attachments for a given message. 61 | pub async fn find_attachments_for( 62 | attachment_cache_path: impl AsRef, 63 | channel_id: ChannelId, 64 | msg_id: MessageId, 65 | ) -> Result> { 66 | let attachment_cache_path = attachment_cache_path.as_ref(); 67 | let attachment_dir = attachment_cache_path.join(generate_dirname(channel_id, msg_id)); 68 | if !attachment_dir.exists() { 69 | return Ok(Vec::new()); 70 | } 71 | let mut read_dir = tokio::fs::read_dir(attachment_dir).await?; 72 | 73 | let mut entries = Vec::new(); 74 | while let Some(entry) = read_dir.next_entry().await? { 75 | if entry.file_type().await?.is_file() { 76 | let file = tokio::fs::File::open(entry.path()).await?; 77 | entries.push((entry.path(), file)); 78 | } 79 | } 80 | 81 | Ok(entries) 82 | } 83 | 84 | #[tracing::instrument(skip_all)] 85 | /// Restrict the disk-space used up by attachment logs by removing old files. 86 | pub async fn cleanup(config: &Config) -> Result<()> { 87 | let mut read_dir = tokio::fs::read_dir(&config.attachment_cache_path).await?; 88 | 89 | let mut files = Vec::new(); 90 | 91 | let mut total_size_bytes = 0usize; 92 | while let Some(entry) = read_dir.next_entry().await? { 93 | let mut read_attachments = tokio::fs::read_dir(entry.path()).await?; 94 | while let Some(attachment) = read_attachments.next_entry().await? { 95 | if attachment.file_type().await?.is_file() { 96 | let metadata = attachment.metadata().await?; 97 | total_size_bytes += metadata.size() as usize; 98 | files.push((attachment, metadata)); 99 | } 100 | } 101 | } 102 | 103 | if total_size_bytes > config.attachment_cache_max_size { 104 | files.sort_by_key(|(_, meta)| meta.modified().expect("Unsupported platform")); 105 | } 106 | 107 | tracing::info!(attachment_logs.total_size_bytes = %total_size_bytes, "Performing attachment cleanup"); 108 | 109 | while total_size_bytes > config.attachment_cache_max_size && !files.is_empty() { 110 | let (file, meta) = files.remove(0); 111 | tracing::trace!(file_name = %file.path().display(), size = meta.size(), "Deleting file"); 112 | tokio::fs::remove_file(file.path()) 113 | .instrument(tracing::info_span!("Deleting file", file_name = %file.path().display(), size = meta.size())) 114 | .await?; 115 | total_size_bytes -= meta.size() as usize; 116 | } 117 | Ok(()) 118 | } 119 | 120 | fn generate_dirname(channel_id: ChannelId, msg_id: MessageId) -> String { 121 | format!("{}-{}", channel_id, msg_id) 122 | } 123 | -------------------------------------------------------------------------------- /crates/robbb/src/events/auto_moderation_action.rs: -------------------------------------------------------------------------------- 1 | use robbb_util::extensions::ClientContextExt; 2 | use serenity::{all::ActionExecution, client}; 3 | 4 | pub async fn execution(ctx: client::Context, execution: ActionExecution) -> anyhow::Result<()> { 5 | tracing::info!( 6 | execution.action = ?execution.action, 7 | execution = ?execution, 8 | "Automod execution: {:?}", 9 | execution.action 10 | ); 11 | if !matches!(execution.action, serenity::all::automod::Action::Alert { .. }) { 12 | return Ok(()); 13 | } 14 | 15 | let Some(matched_keyword) = execution.matched_keyword else { return Ok(()) }; 16 | let Some(matched_content) = execution.matched_content else { return Ok(()) }; 17 | let Some(message_id) = execution.message_id.or(execution.alert_system_message_id) else { 18 | return Ok(()); 19 | }; 20 | let Some(channel_id) = execution.channel_id else { return Ok(()) }; 21 | tracing::info!( 22 | msg.id = %message_id, 23 | msg.author_id = %execution.user_id, 24 | msg.channel_id = %channel_id, 25 | automod.matched_content = %matched_content, 26 | automod.matched_keyword = %matched_keyword, 27 | "Automod alerted about message" 28 | ); 29 | 30 | let db = ctx.get_db().await; 31 | 32 | let bot_id = ctx.cache.current_user().id; 33 | let note_content = format!("Automod deleted message because of word `{matched_content}`"); 34 | db.add_mod_action( 35 | bot_id, 36 | execution.user_id, 37 | note_content, 38 | chrono::Utc::now(), 39 | message_id.link(channel_id, Some(execution.guild_id)), 40 | robbb_db::mod_action::ModActionKind::BlocklistViolation, 41 | ) 42 | .await?; 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /crates/robbb/src/events/guild_audit_log_entry_create.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use robbb_util::extensions::{ClientContextExt, CreateEmbedExt, UserExt}; 3 | use serenity::{ 4 | all::{audit_log, AuditLogEntry, UserId}, 5 | client, 6 | }; 7 | 8 | pub async fn guild_audit_log_entry_create( 9 | ctx: client::Context, 10 | entry: AuditLogEntry, 11 | ) -> anyhow::Result<()> { 12 | tracing::info!( 13 | auditlog.entry.id = %entry.id, 14 | auditlog.entry.action = ?entry.action, 15 | auditlog.entry = ?entry, 16 | "New audit log entry created with action {:?}", 17 | entry.action 18 | ); 19 | 20 | if entry.user_id == ctx.cache.current_user().id { 21 | return Ok(()); 22 | } 23 | let (config, db) = ctx.get_config_and_db().await; 24 | let user = entry.user_id.to_user(&ctx).await?; 25 | let Some(target_id) = entry.target_id else { return Ok(()) }; 26 | match entry.action { 27 | audit_log::Action::Member(audit_log::MemberAction::BanAdd) => { 28 | let target_user = UserId::new(target_id.get()).to_user(&ctx).await?; 29 | db.add_mod_action( 30 | user.id, 31 | target_user.id, 32 | entry.reason.clone().unwrap_or_default(), 33 | Utc::now(), 34 | String::new(), 35 | robbb_db::mod_action::ModActionKind::Ban, 36 | ) 37 | .await?; 38 | config 39 | .log_bot_action(&ctx, |e| { 40 | e.title("Ban") 41 | .author_user(&user) 42 | .description(format!( 43 | "manually yote user: {}", 44 | target_user.mention_and_tag() 45 | )) 46 | .field_opt("Reason", entry.reason, false) 47 | }) 48 | .await; 49 | } 50 | audit_log::Action::Member(audit_log::MemberAction::BanRemove) => { 51 | let target_user = UserId::new(target_id.get()).to_user(&ctx).await?; 52 | config 53 | .log_bot_action(&ctx, |e| { 54 | e.title("Unban") 55 | .author_user(&user) 56 | .description(format!( 57 | "manually unbanned user: {}", 58 | target_user.mention_and_tag() 59 | )) 60 | .field_opt("Reason", entry.reason, false) 61 | }) 62 | .await; 63 | } 64 | 65 | audit_log::Action::Member(audit_log::MemberAction::Kick) => { 66 | let target_user = UserId::new(target_id.get()).to_user(&ctx).await?; 67 | db.add_mod_action( 68 | user.id, 69 | target_user.id, 70 | entry.reason.clone().unwrap_or_default(), 71 | Utc::now(), 72 | String::new(), 73 | robbb_db::mod_action::ModActionKind::Kick, 74 | ) 75 | .await?; 76 | config 77 | .log_bot_action(&ctx, |e| { 78 | e.title("Kick") 79 | .author_user(&user) 80 | .description(format!( 81 | "manually kicked user: {}", 82 | target_user.mention_and_tag() 83 | )) 84 | .field_opt("Reason", entry.reason, false) 85 | }) 86 | .await; 87 | } 88 | _ => {} 89 | } 90 | Ok(()) 91 | } 92 | -------------------------------------------------------------------------------- /crates/robbb/src/events/guild_member_addition.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use chrono::{DateTime, Utc}; 3 | use poise::serenity_prelude::{Member, Mentionable}; 4 | use robbb_commands::commands; 5 | use robbb_util::{ 6 | embeds, 7 | extensions::{ChannelIdExt, ClientContextExt, UserExt}, 8 | log_error, util, 9 | }; 10 | use serenity::builder::CreateEmbedAuthor; 11 | use std::time::SystemTime; 12 | 13 | #[tracing::instrument(skip_all)] 14 | async fn handle_htm_evasion(ctx: &client::Context, new_member: &mut Member) -> Result<()> { 15 | let (config, db) = ctx.get_config_and_db().await; 16 | let is_htm = db.check_user_htm(new_member.user.id).await?; 17 | if is_htm { 18 | tracing::info!("Re-adding hard-to-moderate-role due to htm evasion"); 19 | config 20 | .channel_modlog 21 | .send_embed_builder(&ctx, |e| { 22 | e.author( 23 | CreateEmbedAuthor::new("HTM evasion caught").icon_url(new_member.user.face()), 24 | ) 25 | .title(new_member.user.name_with_disc_and_id()) 26 | .description(format!( 27 | "User {} was HTM and rejoined.\nRe-applying HTM role.", 28 | new_member.mention() 29 | )) 30 | }) 31 | .await?; 32 | new_member.add_role(&ctx, config.role_htm).await?; 33 | } 34 | Ok(()) 35 | } 36 | 37 | /// check if there's an active mute of a user that just joined. 38 | /// if so, reapply the mute and log their mute-evasion attempt in modlog 39 | #[tracing::instrument(skip_all)] 40 | async fn handle_mute_evasion(ctx: &client::Context, new_member: &Member) -> Result<()> { 41 | let (config, db) = ctx.get_config_and_db().await; 42 | let active_mute = db.get_active_mute(new_member.user.id).await?; 43 | if let Some(mute) = active_mute { 44 | tracing::info!("Re-adding mute-role due to mute evasion"); 45 | commands::mute::set_mute_role(&ctx, new_member.clone()).await?; 46 | let embed = embeds::base_embed_ctx(ctx) 47 | .await 48 | .author(CreateEmbedAuthor::new("Mute evasion caught").icon_url(new_member.user.face())) 49 | .title(new_member.user.name_with_disc_and_id()) 50 | .description(format!( 51 | "User {} was muted and rejoined.\nReadding the mute role.", 52 | new_member.mention() 53 | )) 54 | .field("Reason", mute.reason, false) 55 | .field("Start", util::format_date_detailed(mute.start_time), false) 56 | .field("End", util::format_date_detailed(mute.end_time), false); 57 | config.channel_modlog.send_embed(&ctx, embed).await?; 58 | } 59 | Ok(()) 60 | } 61 | 62 | pub async fn guild_member_addition(ctx: client::Context, mut new_member: Member) -> Result<()> { 63 | tracing::info!(user.id = %new_member.user.id, user.name = %new_member.user.tag(), "Handling guild_member_addtion"); 64 | let config = ctx.get_config().await; 65 | if config.guild != new_member.guild_id { 66 | return Ok(()); 67 | } 68 | 69 | log_error!(handle_htm_evasion(&ctx, &mut new_member).await); 70 | log_error!(handle_mute_evasion(&ctx, &new_member).await); 71 | 72 | let account_created_at = new_member.user.created_at(); 73 | config 74 | .channel_bot_traffic 75 | .send_embed_builder(&ctx, |mut e| { 76 | e = e 77 | .author(CreateEmbedAuthor::new("Member Join").icon_url(new_member.user.face())) 78 | .title(new_member.user.name_with_disc_and_id()) 79 | .description(format!("User {} joined the server", new_member.mention())); 80 | if let Some(join_date) = new_member.joined_at { 81 | e = e.field( 82 | "Account Creation Date", 83 | format!( 84 | "{} ({})", 85 | util::format_date(*account_created_at), 86 | util::format_date_before_plaintext(*account_created_at, *join_date) 87 | .replace("ago", "before joining") 88 | ), 89 | false, 90 | ); 91 | e = e.field("Join Date", util::format_date(*join_date), false); 92 | } else { 93 | e = e.field( 94 | "Account Creation Date", 95 | util::format_date_detailed(*account_created_at), 96 | false, 97 | ); 98 | } 99 | if DateTime::::from(SystemTime::now()) 100 | .signed_duration_since(*account_created_at) 101 | .num_days() 102 | <= 3 103 | { 104 | e = e.color(serenity::all::Colour::from_rgb(253, 242, 0)); 105 | } 106 | e 107 | }) 108 | .await?; 109 | Ok(()) 110 | } 111 | -------------------------------------------------------------------------------- /crates/robbb/src/events/guild_member_removal.rs: -------------------------------------------------------------------------------- 1 | use robbb_db::Db; 2 | use serenity::builder::CreateEmbedAuthor; 3 | 4 | use super::*; 5 | 6 | pub async fn guild_member_removal( 7 | ctx: client::Context, 8 | guild_id: GuildId, 9 | user: User, 10 | member: Option, 11 | ) -> Result<()> { 12 | let db: Arc = ctx.get_db().await; 13 | let config = ctx.get_config().await; 14 | if config.guild != guild_id { 15 | return Ok(()); 16 | } 17 | 18 | tracing::info!( 19 | user.id = %user.id, 20 | user.name = %user.tag(), 21 | "Handling guild_member_removal event for user {}", 22 | user.tag(), 23 | ); 24 | 25 | if let Some(member) = member { 26 | let roles = member.roles(&ctx).unwrap_or_default(); 27 | let is_htm = db.check_user_htm(member.user.id).await?; // check if already htm is added to DB 28 | 29 | if roles.iter().any(|x| x.id == config.role_htm) && !is_htm { 30 | // add htm if not in db already 31 | log_error!(db.add_htm(member.user.id).await); 32 | } else { 33 | // remove htm from db if user doesn't have htm anymore 34 | log_error!(db.remove_htm(member.user.id).await); 35 | } 36 | } 37 | 38 | config 39 | .channel_bot_traffic 40 | .send_embed_builder(&ctx, |e| { 41 | e.author(CreateEmbedAuthor::new("Member Leave").icon_url(user.face())) 42 | .title(user.name_with_disc_and_id()) 43 | .field("Leave Date", util::format_date(chrono::Utc::now()), false) 44 | }) 45 | .await?; 46 | db.rm_highlights_of(user.id).await?; 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /crates/robbb/src/events/guild_member_update.rs: -------------------------------------------------------------------------------- 1 | use serenity::builder::EditMember; 2 | use tracing_futures::Instrument; 3 | 4 | use super::*; 5 | 6 | static HOISTING_CHAR: &[char] = &['!', '"', '#', '$', '\'', '(', ')', '*', '-', '+', '.', '/', '=']; 7 | 8 | pub async fn guild_member_update( 9 | ctx: client::Context, 10 | _old: Option, 11 | new: Option, 12 | event: GuildMemberUpdateEvent, 13 | ) -> Result<()> { 14 | let (config, db) = ctx.get_config_and_db().await; 15 | if let Some(new) = new { 16 | dehoist_member(ctx.clone(), new.clone()).await?; 17 | } 18 | 19 | if event.roles.iter().any(|x| *x == config.role_htm) { 20 | log_error!(db.add_htm(event.user.id).await); 21 | } 22 | 23 | Ok(()) 24 | } 25 | 26 | pub async fn dehoist_member(ctx: client::Context, mut member: Member) -> Result<()> { 27 | let display_name = member.display_name().to_string(); 28 | if !display_name.starts_with(HOISTING_CHAR) { 29 | return Ok(()); 30 | } 31 | let cleaned_name = display_name.trim_start_matches(HOISTING_CHAR); 32 | // If the users name is _exclusively_ hoisting chars, just prepend a couple "z"s to put them at the very bottom. 33 | let cleaned_name = if cleaned_name.is_empty() { 34 | format!("zzz{}", display_name) 35 | } else { 36 | cleaned_name.to_string() 37 | }; 38 | tracing::info!(user.old_name = %display_name, user.cleaned_name = %cleaned_name, "Dehoisting user"); 39 | let tag = member.user.tag(); 40 | member 41 | .edit(&ctx, EditMember::default().nickname(&cleaned_name)) 42 | .instrument(tracing::info_span!("dehoist-edit-nickname", member.tag = %tag, dehoist.old_nick = %display_name, dehoist.new_nick = %cleaned_name)) 43 | .await 44 | .with_context(|| format!("Failed to rename user {tag}"))?; 45 | Ok(()) 46 | } 47 | -------------------------------------------------------------------------------- /crates/robbb/src/events/message_update.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude::MessageUpdateEvent; 2 | 3 | use super::*; 4 | 5 | pub async fn message_update( 6 | ctx: &client::Context, 7 | old_if_available: Option, 8 | new: Option, 9 | event: MessageUpdateEvent, 10 | ) -> Result<()> { 11 | let config = ctx.get_config().await; 12 | 13 | if Some(config.guild) != event.guild_id 14 | || event.edited_timestamp.is_none() 15 | || event.author.as_ref().map(|x| x.bot).unwrap_or(false) 16 | { 17 | return Ok(()); 18 | }; 19 | 20 | let old_content = old_if_available 21 | .as_ref() 22 | .map(|x| x.content.to_string()) 23 | .unwrap_or_else(|| "".to_string()); 24 | 25 | if let Some(new) = new { 26 | tracing::info!( 27 | msg.id = %event.id, 28 | msg.author = %new.author.tag(), 29 | msg.author_id = %new.author.id, 30 | msg.channel = %new.channel_id.name_cached_or_fallback(&ctx.cache), 31 | msg.channel_id = %new.channel_id, 32 | msg.content = %new.content, 33 | msg.old_content = %old_content, 34 | "handling message_update event" 35 | ); 36 | } else { 37 | tracing::info!(msg.id = %event.id, "handling message_update event"); 38 | } 39 | 40 | let mut msg = event.channel_id.message(&ctx, event.id).await?; 41 | msg.guild_id = event.guild_id; 42 | 43 | match handle_blocklist::handle_blocklist(&ctx, &msg).await { 44 | Ok(false) => {} 45 | err => log_error!("error while handling blocklist in message_update", err), 46 | }; 47 | 48 | let channel_name = 49 | util::channel_name(&ctx, event.channel_id).await.unwrap_or_else(|_| "unknown".to_string()); 50 | 51 | config 52 | .guild 53 | .send_embed(&ctx, config.channel_bot_messages, |e| { 54 | e.timestamp_opt(event.edited_timestamp) 55 | .author_icon("Message Edit", msg.author.face()) 56 | .title(msg.author.name_with_disc_and_id()) 57 | .description(indoc::formatdoc!( 58 | " 59 | **Before:** 60 | {} 61 | 62 | **Now:** 63 | {} 64 | 65 | {} 66 | ", 67 | old_content, 68 | event.content.clone().unwrap_or_else(|| "".to_string()), 69 | msg.to_context_link() 70 | )) 71 | .footer_str(format!("#{channel_name}")) 72 | }) 73 | .await?; 74 | Ok(()) 75 | } 76 | -------------------------------------------------------------------------------- /crates/robbb/src/events/reaction_add.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | use robbb_db::emoji_logging::EmojiIdentifier; 4 | use serenity::model::channel::ReactionType::Custom; 5 | 6 | pub async fn reaction_add(ctx: client::Context, event: Reaction) -> Result<()> { 7 | let user = event.user(&ctx).await?; 8 | if user.bot { 9 | return Ok(()); 10 | } 11 | let msg = event.message(&ctx).await?; 12 | if msg.reactions.iter().any(|x| x.reaction_type == event.emoji && x.count == 1) { 13 | handle_reaction_emoji_logging(ctx, event).await?; 14 | } 15 | Ok(()) 16 | } 17 | 18 | #[tracing::instrument(skip(ctx))] 19 | async fn handle_reaction_emoji_logging(ctx: client::Context, event: Reaction) -> Result<()> { 20 | let Custom { id, animated, name, .. } = event.emoji else { return Ok(()) }; 21 | let name = name.context("Could not find name for emoji")?; 22 | 23 | let guild_emojis = ctx 24 | .get_guild_emojis(event.guild_id.context("Not in a guild")?) 25 | .await 26 | .context("Could not get guild emojis")?; 27 | if !guild_emojis.contains_key(&id) { 28 | return Ok(()); 29 | }; 30 | 31 | let db = ctx.get_db().await; 32 | db.alter_emoji_reaction_count(1, &EmojiIdentifier { animated, id, name }).await?; 33 | 34 | Ok(()) 35 | } 36 | -------------------------------------------------------------------------------- /crates/robbb/src/events/reaction_remove.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | use robbb_db::emoji_logging::EmojiIdentifier; 4 | use serenity::model::channel::ReactionType::Custom; 5 | 6 | pub async fn reaction_remove(ctx: client::Context, event: Reaction) -> Result<()> { 7 | let user = event.user(&ctx).await?; 8 | if user.bot { 9 | return Ok(()); 10 | } 11 | 12 | let msg = event.message(&ctx).await?; 13 | if !msg.reactions.iter().any(|x| x.reaction_type == event.emoji) { 14 | handle_emoji_removal(ctx, event).await?; 15 | } 16 | Ok(()) 17 | } 18 | 19 | #[tracing::instrument(skip(ctx))] 20 | pub async fn handle_emoji_removal(ctx: client::Context, event: Reaction) -> Result<()> { 21 | let Custom { id, animated, name, .. } = event.emoji else { return Ok(()) }; 22 | let name = name.context("Could not find name for emoji")?; 23 | 24 | let guild_emojis = ctx 25 | .get_guild_emojis(event.guild_id.context("Not in a guild")?) 26 | .await 27 | .context("Could not get guild emojis")?; 28 | if !guild_emojis.contains_key(&id) { 29 | return Ok(()); 30 | }; 31 | 32 | let db = ctx.get_db().await; 33 | db.alter_emoji_reaction_count(-1, &EmojiIdentifier { animated, id, name }).await?; 34 | 35 | Ok(()) 36 | } 37 | -------------------------------------------------------------------------------- /crates/robbb/src/events/ready.rs: -------------------------------------------------------------------------------- 1 | use robbb_commands::modlog; 2 | use serenity::futures::StreamExt; 3 | 4 | use super::*; 5 | 6 | pub async fn ready(ctx: client::Context, _data_about_bot: Ready) -> Result<()> { 7 | let config = ctx.get_config().await; 8 | 9 | let bot_version = util::BotVersion::get(); 10 | tracing::info!( 11 | version.profile = %bot_version.profile, 12 | version.commit_hash = %bot_version.commit_hash, 13 | version.commit_msg = %bot_version.commit_msg, 14 | "Robbb is ready!" 15 | ); 16 | 17 | let _ = config 18 | .channel_mod_bot_stuff 19 | .send_embed_builder(&ctx, |e| { 20 | e.title("Hey guys, I'm back!") 21 | .field("profile", bot_version.profile, true) 22 | .field("commit", bot_version.commit_link(), true) 23 | .field("message", bot_version.commit_msg, false) 24 | }) 25 | .await; 26 | 27 | dehoist_everyone(ctx.clone(), config.guild).await; 28 | 29 | start_mute_handler(ctx.clone()).await; 30 | start_attachment_log_handler(ctx).await; 31 | Ok(()) 32 | } 33 | 34 | #[tracing::instrument(skip_all)] 35 | async fn dehoist_everyone(ctx: client::Context, guild_id: GuildId) { 36 | guild_id 37 | .members_iter(&ctx) 38 | .filter_map(|x| async { x.ok() }) 39 | .for_each_concurrent(None, |member| async { 40 | log_error!( 41 | "Error while dehoisting a member", 42 | guild_member_update::dehoist_member(ctx.clone(), member).await 43 | ); 44 | }) 45 | .await; 46 | } 47 | 48 | /// End a given mute, ending the users timeout and removing the mute role, 49 | /// as well as setting the mute to inactive in the db. 50 | #[tracing::instrument(skip_all, fields(user.id = %mute.user, mute.id = %mute.id))] 51 | async fn unmute(ctx: &client::Context, mute: &robbb_db::mute::Mute) -> Result<()> { 52 | let (config, db) = ctx.get_config_and_db().await; 53 | db.set_mute_inactive(mute.id).await?; 54 | let mut member = config.guild.member(&ctx, mute.user).await?; 55 | log_error!(member.remove_roles(&ctx, &[config.role_mute]).await); 56 | log_error!(member.enable_communication(&ctx).await); 57 | Ok(()) 58 | } 59 | 60 | async fn start_mute_handler(ctx: client::Context) { 61 | let db = ctx.get_db().await; 62 | tokio::spawn(async move { 63 | loop { 64 | tokio::time::sleep(std::time::Duration::from_secs(30)).await; 65 | let mutes = match db.get_newly_expired_mutes().await { 66 | Ok(mutes) => mutes, 67 | Err(err) => { 68 | tracing::error!(error.message = %err, "Failed to request expired mutes"); 69 | continue; 70 | } 71 | }; 72 | for mute in mutes { 73 | tracing::info!( 74 | user.id = %mute.user, 75 | mute.id = %mute.id, 76 | mute.end_time = %mute.end_time, 77 | "Mute expired for user {}, unmuting", mute.user 78 | ); 79 | if let Err(err) = unmute(&ctx, &mute).await { 80 | tracing::error!( 81 | error.message = %err, 82 | error = ?err, 83 | mute.id = %mute.id, 84 | user.id = %mute.user, 85 | "Error handling mute removal" 86 | ); 87 | } else { 88 | modlog::log_user_mute_ended(&ctx, &mute).await; 89 | } 90 | } 91 | } 92 | }); 93 | } 94 | 95 | async fn start_attachment_log_handler(ctx: client::Context) { 96 | let config = ctx.get_config().await; 97 | tokio::spawn(async move { 98 | loop { 99 | tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; 100 | 101 | log_error!( 102 | "Failed to clean up attachments", 103 | crate::attachment_logging::cleanup(&config).await 104 | ); 105 | } 106 | }); 107 | } 108 | -------------------------------------------------------------------------------- /crates/robbb/src/re_exports.rs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unixporn/robbb/9af1e242d7ca593aa19ae3bbf19cd71f390d9c61/crates/robbb/src/re_exports.rs -------------------------------------------------------------------------------- /crates/robbb_commands/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "robbb_commands" 3 | version = "0.1.0" 4 | edition = "2021" 5 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 6 | 7 | [dependencies] 8 | serenity.workspace = true 9 | poise.workspace = true 10 | anyhow = "1.0.82" 11 | chrono = "0.4.38" 12 | chrono-humanize = "0.2.3" 13 | itertools = "0.11.0" 14 | indoc = "2.0.5" 15 | lazy_static = "1.4" 16 | humantime = "2.1.0" 17 | thiserror = "1.0.59" 18 | serde_json = "1.0.116" 19 | serde = "1.0.200" 20 | maplit = "1.0.2" 21 | byte-unit = { version = "5.1.4", features = ["u128"] } 22 | regex = "1.10.4" 23 | reqwest = { version = "0.11" } 24 | tokio = { version = "1.21", features = ["macros", "fs", "rt-multi-thread"] } 25 | tokio-util = { version = "0.7.10", features = ["compat"] } 26 | futures = "0.3.30" 27 | 28 | unicase = "2.7.0" 29 | 30 | tracing = "0.1.40" 31 | tracing-futures = "0.2.5" 32 | parking_lot = "0.12.2" 33 | 34 | robbb_db = { path = "../robbb_db" } 35 | robbb_util = { path = "../robbb_util" } 36 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/checks.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude::{RoleId, User}; 2 | use robbb_util::{ 3 | extensions::{ClientContextExt, PoiseContextExt}, 4 | prelude::{Ctx, Res}, 5 | }; 6 | use serenity::client; 7 | 8 | /// Check if the channel allows the use of the given command. 9 | pub async fn check_channel_allows_commands(ctx: Ctx<'_>) -> Res { 10 | let config = ctx.get_config(); 11 | let channel_id = ctx.channel_id(); 12 | Ok(!(channel_id == config.channel_showcase 13 | || channel_id == config.channel_feedback 14 | || channel_id == config.channel_tech_support)) 15 | } 16 | 17 | #[tracing::instrument(skip_all)] 18 | pub async fn check_is_moderator(ctx: Ctx<'_>) -> Res { 19 | let config = ctx.get_config(); 20 | check_role(ctx.serenity_context(), ctx.author(), config.role_mod).await 21 | } 22 | 23 | #[tracing::instrument(skip_all)] 24 | pub async fn check_is_helper(ctx: Ctx<'_>) -> Res { 25 | let config = ctx.get_config(); 26 | check_role(ctx.serenity_context(), ctx.author(), config.role_helper).await 27 | } 28 | 29 | #[tracing::instrument(skip_all)] 30 | pub async fn check_is_helper_or_mod(ctx: Ctx<'_>) -> Res { 31 | let permission_level = get_permission_level(ctx.serenity_context(), ctx.author()).await?; 32 | match permission_level { 33 | PermissionLevel::User => Ok(false), 34 | PermissionLevel::Helper | PermissionLevel::Mod => Ok(true), 35 | } 36 | } 37 | 38 | #[tracing::instrument(skip_all)] 39 | pub async fn check_is_not_muted(ctx: Ctx<'_>) -> Res { 40 | let config = ctx.get_config(); 41 | check_role(ctx.serenity_context(), ctx.author(), config.role_mute).await.map(|x| !x) 42 | } 43 | 44 | #[tracing::instrument(skip_all, fields(user_id = %user.id.get(), role_id = %role.get()))] 45 | async fn check_role(ctx: &client::Context, user: &User, role: RoleId) -> Res { 46 | let config = ctx.get_config().await; 47 | Ok(user.has_role(ctx, config.guild, role).await?) 48 | } 49 | 50 | /// Level of permission a given user has. Ordered such that Mod > Helper > User. 51 | #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] 52 | pub enum PermissionLevel { 53 | User, 54 | Helper, 55 | Mod, 56 | } 57 | 58 | #[tracing::instrument(skip_all)] 59 | pub async fn get_permission_level(ctx: &client::Context, user: &User) -> Res { 60 | let config = ctx.get_config().await; 61 | Ok(if check_role(ctx, user, config.role_mod).await? { 62 | PermissionLevel::Mod 63 | } else if check_role(ctx, user, config.role_helper).await? { 64 | PermissionLevel::Helper 65 | } else { 66 | PermissionLevel::User 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/attachment_hack.rs: -------------------------------------------------------------------------------- 1 | use robbb_util::{ 2 | cdn_hack::{self, FakeCdnId}, 3 | log_error, 4 | }; 5 | 6 | use super::*; 7 | 8 | /// Gather attachments, re-post them in a storage channel, update DB 9 | #[poise::command( 10 | slash_command, 11 | guild_only, 12 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 13 | )] 14 | pub async fn gather_attachments(ctx: Ctx<'_>) -> Res<()> { 15 | ctx.defer().await?; 16 | let db = ctx.get_db(); 17 | 18 | let tag_names = db.list_tags().await?; 19 | for tag_name in &tag_names { 20 | let Some(tag) = db.get_tag(tag_name).await? else { continue }; 21 | 22 | let metadata = serde_json::json!({"kind": "tag", "tag_name": tag_name}); 23 | let result = 24 | cdn_hack::persist_cdn_links_in_string(ctx.serenity_context(), &tag.content, metadata) 25 | .await; 26 | 27 | let new_content = match result { 28 | Ok(x) => x, 29 | err => { 30 | log_error!(err); 31 | continue; 32 | } 33 | }; 34 | 35 | if new_content != tag.content { 36 | db.set_tag( 37 | tag.moderator, 38 | tag.name.to_string(), 39 | new_content, 40 | tag.official, 41 | tag.create_date, 42 | ) 43 | .await?; 44 | } 45 | } 46 | 47 | ctx.say_success("Successfully went through tag data and re-uploaded attachments!").await?; 48 | 49 | let fetches = db.get_all_fetches().await?; 50 | for fetch in fetches { 51 | let Some(image_url) = fetch.info.get(&robbb_db::fetch_field::FetchField::Image) else { 52 | continue; 53 | }; 54 | 55 | if image_url.parse::().is_ok() { 56 | tracing::info!(user = %fetch.user, "Skipping already-fake CDN image in fetch: {image_url}"); 57 | continue; 58 | } 59 | 60 | let metadata = serde_json::json!({ 61 | "kind": "fetch".to_string(), 62 | "user_id": fetch.user.get(), 63 | "original_url": image_url 64 | }); 65 | 66 | let result = 67 | cdn_hack::persist_attachment(ctx.serenity_context(), image_url, metadata).await; 68 | let fake_cdn_id = match result { 69 | Ok(x) => x, 70 | err => { 71 | log_error!(err); 72 | continue; 73 | } 74 | }; 75 | 76 | db.update_fetch( 77 | fetch.user, 78 | maplit::hashmap! {robbb_db::fetch_field::FetchField::Image => fake_cdn_id.encode() }, 79 | ) 80 | .await?; 81 | } 82 | 83 | ctx.say_success("Successfully went through fetch data and re-uploaded attachments!").await?; 84 | 85 | Ok(()) 86 | } 87 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/blocklist.rs: -------------------------------------------------------------------------------- 1 | use regex::Regex; 2 | use robbb_util::embeds; 3 | 4 | use super::*; 5 | 6 | pub static SHOULD_NEVER_TRIGGER_BLOCKLIST: &[&str] = &[ 7 | "", 8 | "Hello, I am new to linux, and I'd love to get some help with my GNOME installation.", 9 | "I use Arch with GNOME, but for some reason, my backspace key doesn't work properly. Someone please help", 10 | ]; 11 | 12 | /// Control the blocklist 13 | #[poise::command( 14 | slash_command, 15 | guild_only, 16 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 17 | subcommands("blocklist_add", "blocklist_remove", "blocklist_list",) 18 | )] 19 | pub async fn blocklist(_ctx: Ctx<'_>) -> Res<()> { 20 | Ok(()) 21 | } 22 | 23 | /// Add a new pattern to the blocklist 24 | #[poise::command( 25 | slash_command, 26 | guild_only, 27 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 28 | rename = "add" 29 | )] 30 | pub async fn blocklist_add( 31 | ctx: Ctx<'_>, 32 | #[description = "Regex pattern for the blocked word"] pattern: String, 33 | ) -> Res<()> { 34 | let db = ctx.get_db(); 35 | 36 | let regex = Regex::new(&pattern).user_error("Illegal regex pattern")?; 37 | 38 | if SHOULD_NEVER_TRIGGER_BLOCKLIST.iter().any(|x| regex.is_match(x)) { 39 | abort_with!("Pattern matches one of the test strings it should never match. Make sure you're not matching the empty string or anything else you don't want to.") 40 | } 41 | 42 | db.add_blocklist_entry(ctx.author().id, &pattern).await?; 43 | 44 | ctx.say_success(format!("Added `{}` to the blocklist", pattern)).await?; 45 | 46 | Ok(()) 47 | } 48 | 49 | /// Remove a pattern from the blocklist 50 | #[poise::command( 51 | slash_command, 52 | guild_only, 53 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 54 | rename = "remove" 55 | )] 56 | pub async fn blocklist_remove( 57 | ctx: Ctx<'_>, 58 | #[autocomplete = "autocomplete_blocklist_entry"] 59 | #[description = "Pattern to remove from the blocklist"] 60 | pattern: String, 61 | ) -> Res<()> { 62 | let db = ctx.get_db(); 63 | 64 | db.remove_blocklist_entry(&pattern).await?; 65 | ctx.say_success(format!("Removed `{}` from the blocklist", pattern)).await?; 66 | 67 | Ok(()) 68 | } 69 | 70 | /// Get all blocklist entries 71 | #[poise::command( 72 | slash_command, 73 | guild_only, 74 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 75 | rename = "list" 76 | )] 77 | pub async fn blocklist_list(ctx: Ctx<'_>) -> Res<()> { 78 | let config = ctx.get_config(); 79 | 80 | let db = ctx.get_db(); 81 | let entries = db.get_blocklist().await?; 82 | 83 | let is_in_mod_bot_stuff = ctx.channel_id() == config.channel_mod_bot_stuff; 84 | let embed = embeds::base_embed(&ctx) 85 | .title("Blocklist") 86 | .description(entries.iter().map(|x| format!("`{x}`")).join("\n")); 87 | if is_in_mod_bot_stuff { 88 | ctx.reply_embed(embed).await?; 89 | } else { 90 | ctx.reply_embed_ephemeral(embed).await?; 91 | } 92 | Ok(()) 93 | } 94 | 95 | async fn autocomplete_blocklist_entry(ctx: Ctx<'_>, partial: &str) -> Vec { 96 | let db = ctx.get_db(); 97 | if let Ok(blocklist) = db.get_blocklist().await { 98 | blocklist.iter().filter(|x| x.contains(partial)).map(|x| x.to_string()).collect_vec() 99 | } else { 100 | Vec::new() 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/emojistats.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | use std::collections::hash_map::HashMap; 4 | 5 | use anyhow::Context; 6 | use poise::serenity_prelude::{Emoji, EmojiId}; 7 | 8 | use robbb_db::emoji_logging::{EmojiStats, Ordering}; 9 | 10 | /// Get statistics about the usage of emotes 11 | #[poise::command( 12 | slash_command, 13 | guild_only, 14 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 15 | )] 16 | pub async fn emojistats( 17 | ctx: Ctx<'_>, 18 | #[description = "Reverse order of popularity"] 19 | #[flag] 20 | #[rename = "ascending"] 21 | sort_ascending: bool, 22 | #[description = "The emote you want statistics on"] emote: Option, 23 | ) -> Res<()> { 24 | let db = ctx.get_db(); 25 | let ordering = match sort_ascending { 26 | true => Ordering::Ascending, 27 | false => Ordering::Descending, 28 | }; 29 | 30 | let guild_emojis = ctx.get_guild_emojis().context("could not get guild emojis")?; 31 | 32 | match emote { 33 | Some(emote_name) => { 34 | let found_emoji = match util::find_emojis(&emote_name).first() { 35 | Some(searched_emoji) => { 36 | db.get_emoji_usage_by_id(&robbb_db::emoji_logging::EmojiIdentifier { 37 | id: searched_emoji.id, 38 | animated: searched_emoji.animated, 39 | name: searched_emoji.name.clone(), 40 | }) 41 | .await 42 | } 43 | None => db.get_emoji_usage_by_name(&emote_name).await, 44 | }; 45 | let emoji_data = found_emoji.user_error("Could not find that emote")?; 46 | 47 | let emoji = guild_emojis 48 | .get(&emoji_data.emoji.id) 49 | .user_error("Could not find emoji in guild")?; 50 | ctx.reply_embed_builder(|e| { 51 | e.title(format!("Emoji usage for *{}*", emoji.name)) 52 | .thumbnail(emoji.url()) 53 | .description(format!( 54 | "**Reactions:** {} \n**In Text:** {} \n**Total:** {}", 55 | emoji_data.reactions, 56 | emoji_data.in_text, 57 | emoji_data.reactions + emoji_data.in_text 58 | )) 59 | }) 60 | .await?; 61 | } 62 | None => { 63 | let emojis = db.get_top_emoji_stats(10, ordering).await?; 64 | ctx.reply_embed_builder(|e| { 65 | e.title("Emoji usage") 66 | .description(display_emoji_list(&guild_emojis, emojis.into_iter())) 67 | }) 68 | .await?; 69 | } 70 | } 71 | Ok(()) 72 | } 73 | 74 | fn display_emoji_list( 75 | guildemojis: &HashMap, 76 | emojis: impl Iterator, 77 | ) -> String { 78 | emojis 79 | .filter_map(|emoji| Some((guildemojis.get(&emoji.emoji.id)?, emoji))) 80 | .enumerate() 81 | .map(|(num, (guild_emoji, emoji))| { 82 | format!( 83 | "{} {} `{}`: total: {}, reaction: {}, in text: {}", 84 | num + 1, 85 | guild_emoji, 86 | guild_emoji.name, 87 | emoji.reactions + emoji.in_text, 88 | emoji.reactions, 89 | emoji.in_text 90 | ) 91 | }) 92 | .join("\n") 93 | } 94 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/errors.rs: -------------------------------------------------------------------------------- 1 | use thiserror::Error; 2 | 3 | #[derive(Error, Debug)] 4 | #[error("{}", .0)] 5 | pub struct UserErr(String); 6 | 7 | impl UserErr { 8 | pub fn new(s: impl Into) -> Self { 9 | Self(s.into()) 10 | } 11 | } 12 | 13 | /// Extension trait for both Option and Result that adds [UserErr] related context methods 14 | pub trait OptionExt { 15 | fn user_error(self, s: &str) -> Result; 16 | } 17 | impl> OptionExt for Result { 18 | fn user_error(self, s: &str) -> Result { 19 | self.map_err(|_| UserErr(s.to_string())) 20 | } 21 | } 22 | 23 | impl OptionExt for Option { 24 | fn user_error(self, s: &str) -> Result { 25 | self.ok_or_else(|| UserErr(s.to_string())) 26 | } 27 | } 28 | 29 | /// Extension trait for Result that adds [UserErr] related context methods 30 | pub trait ResultExt { 31 | fn with_user_error(self, f: impl FnOnce(E) -> String) -> Result; 32 | } 33 | 34 | impl> ResultExt for Result { 35 | fn with_user_error(self, f: impl FnOnce(E) -> String) -> Result { 36 | self.map_err(|e| UserErr(f(e))) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/fetch/fetch.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashMap, str::FromStr}; 2 | 3 | use anyhow::Context; 4 | use robbb_db::{fetch::Fetch, fetch_field::FetchField}; 5 | use robbb_util::{cdn_hack::FakeCdnId, embeds}; 6 | use serenity::{all::User, builder::CreateEmbedAuthor}; 7 | 8 | use super::*; 9 | 10 | /// Fetch a users system information. 11 | #[poise::command(slash_command, guild_only, rename = "fetch")] 12 | pub async fn fetch( 13 | ctx: Ctx<'_>, 14 | #[description = "The user"] user: Option, 15 | #[description = "The specific field you care about"] field: Option, 16 | ) -> Res<()> { 17 | let guild_id = ctx.guild_id().context("Not in a guild")?; 18 | let db = ctx.get_db(); 19 | let user = user.unwrap_or_else(|| ctx.author().clone()); 20 | ctx.defer().await?; 21 | 22 | // Query the database 23 | let fetch_info: Fetch = db.get_fetch(user.id).await?.unwrap_or_else(|| Fetch { 24 | user: user.id, 25 | info: HashMap::new(), 26 | create_date: None, 27 | }); 28 | 29 | let create_date = fetch_info.create_date; 30 | let fetch_data: Vec<(FetchField, String)> = fetch_info.get_values_ordered(); 31 | let color = if let Ok(member) = guild_id.member(&ctx, user.id).await { 32 | member.colour(ctx.serenity_context()) 33 | } else { 34 | None 35 | }; 36 | 37 | match field { 38 | // Handle fetching a single field 39 | Some(desired_field) => { 40 | let (field_name, value) = fetch_data 41 | .into_iter() 42 | .find(|(k, _)| k == &desired_field) 43 | .user_error("Failed to get that value. Maybe the user hasn't set it?")?; 44 | let mut embed = embeds::base_embed(&ctx) 45 | .author(CreateEmbedAuthor::new(user.tag()).icon_url(user.face())) 46 | .title(format!("{}'s {}", user.name, field_name)) 47 | .color_opt(color) 48 | .timestamp_opt(create_date); 49 | if desired_field == FetchField::Image { 50 | let new_link = FakeCdnId::from_str(&value)?.resolve(&ctx).await?; 51 | embed = embed.image(new_link); 52 | } else if let Some(value) = format_fetch_field_value(&field_name, value) { 53 | embed = embed.description(value); 54 | } else { 55 | embed = embed.description("Not set"); 56 | } 57 | ctx.reply_embed(embed).await?; 58 | } 59 | 60 | // Handle fetching all fields 61 | None => { 62 | let mut embed = embeds::base_embed(&ctx) 63 | .author_user(&user) 64 | .color_opt(color) 65 | .timestamp_opt(create_date); 66 | 67 | for (key, value) in fetch_data { 68 | if key == FetchField::Image { 69 | let new_link = FakeCdnId::from_str(&value)?.resolve(&ctx).await?; 70 | embed = embed.image(new_link); 71 | } else { 72 | if key == FetchField::Distro { 73 | if let Some(url) = find_distro_image(&value) { 74 | embed = embed.thumbnail(url); 75 | } 76 | } 77 | embed = embed.field_opt( 78 | key.to_string(), 79 | format_fetch_field_value(&key, value), 80 | true, 81 | ); 82 | } 83 | } 84 | ctx.reply_embed(embed).await?; 85 | } 86 | } 87 | 88 | Ok(()) 89 | } 90 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/fetch/setfetch.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use poise::serenity_prelude::Attachment; 3 | use robbb_util::cdn_hack; 4 | use std::str::FromStr; 5 | 6 | use super::*; 7 | use std::collections::HashMap; 8 | 9 | const SETFETCH_USAGE: &str = indoc::indoc!(" 10 | Run this: 11 | `curl -s https://raw.githubusercontent.com/unixporn/robbb/master/fetcher.sh | sh` 12 | and follow the instructions. It's recommended that you download and read the script before running it, 13 | as piping curl to sh isn't always the safest practice. () 14 | 15 | **NOTE**: use `/setfetch update` to manually update your fetch (including the image!). 16 | **NOTE**: /git, /dotfiles, and /description are different commands" 17 | ); 18 | 19 | /// Set your fetch data 20 | #[poise::command( 21 | slash_command, 22 | guild_only, 23 | rename = "setfetch", 24 | subcommands("set_fetch_script", "set_fetch_update", "set_fetch_clear") 25 | )] 26 | pub async fn set_fetch(_ctx: Ctx<'_>) -> Res<()> { 27 | Ok(()) 28 | } 29 | 30 | /// Use our custom fetch script to fill in your entire fetch automatically! 31 | #[poise::command(slash_command, guild_only, rename = "script")] 32 | pub async fn set_fetch_script(ctx: Ctx<'_>) -> Res<()> { 33 | ctx.reply_embed_ephemeral_builder(|e| e.description(SETFETCH_USAGE)).await?; 34 | Ok(()) 35 | } 36 | 37 | /// Update your fetch data 38 | #[poise::command(slash_command, guild_only, rename = "update")] 39 | #[allow(clippy::too_many_arguments)] 40 | pub async fn set_fetch_update( 41 | ctx: Ctx<'_>, 42 | #[description = "Image"] image: Option, 43 | #[description = "Distro"] distro: Option, 44 | #[description = "Kernel"] kernel: Option, 45 | #[description = "Terminal"] terminal: Option, 46 | #[description = "Editor"] editor: Option, 47 | #[description = "Shell"] shell: Option, 48 | #[description = "De_wm"] de_wm: Option, 49 | #[description = "Bar"] bar: Option, 50 | #[description = "Resolution"] resolution: Option, 51 | #[description = "Display Protocol"] display_protocol: Option, 52 | #[description = "GTK3 Theme"] gtk3_theme: Option, 53 | #[description = "GTK Icon Theme"] gtk_icon_theme: Option, 54 | #[description = "CPU"] cpu: Option, 55 | #[description = "GPU"] gpu: Option, 56 | #[description = "Description"] description: Option, 57 | #[description = "Dotfiles"] dotfiles: Option, 58 | #[description = "Git"] git: Option, 59 | #[description = "Memory"] memory: Option, 60 | ) -> Res<()> { 61 | let image = match image { 62 | Some(attachment) => { 63 | ctx.defer().await?; 64 | let meta = 65 | serde_json::json!({"kind": "fetch".to_string(), "user_id": ctx.author().id.get() }); 66 | Some( 67 | cdn_hack::persist_attachment(ctx.serenity_context(), &attachment.url, meta) 68 | .await? 69 | .encode(), 70 | ) 71 | } 72 | None => None, 73 | }; 74 | 75 | let memory = match memory { 76 | Some(memory) => Some( 77 | byte_unit::Byte::from_str(&memory) 78 | .user_error("Malformed value provided for Memory")? 79 | .as_u128() 80 | .to_string(), 81 | ), 82 | _ => None, 83 | }; 84 | 85 | let data = maplit::hashmap! { 86 | FetchField::Image => image, 87 | FetchField::Distro => distro, 88 | FetchField::Kernel => kernel, 89 | FetchField::Terminal => terminal, 90 | FetchField::Editor => editor, 91 | FetchField::Shell => shell, 92 | FetchField::DEWM => de_wm, 93 | FetchField::Bar => bar, 94 | FetchField::Resolution => resolution, 95 | FetchField::DisplayProtocol => display_protocol, 96 | FetchField::GTK3 => gtk3_theme, 97 | FetchField::Icons => gtk_icon_theme, 98 | FetchField::CPU => cpu, 99 | FetchField::GPU => gpu, 100 | FetchField::Dotfiles => dotfiles, 101 | FetchField::Description => description, 102 | FetchField::Git => git, 103 | FetchField::Memory => memory, 104 | }; 105 | let info = data.into_iter().filter_map(|(k, v)| Some((k, v?))).collect(); 106 | let db = ctx.get_db(); 107 | db.update_fetch(ctx.author().id, info).await?; 108 | ctx.say_success("Successfully updated your fetch data!").await?; 109 | 110 | Ok(()) 111 | } 112 | 113 | /// Clear your fetch data 114 | #[poise::command(slash_command, guild_only, rename = "clear")] 115 | pub async fn set_fetch_clear( 116 | ctx: Ctx<'_>, 117 | #[description = "Field you want to clear"] field: Option, 118 | ) -> Res<()> { 119 | let db = ctx.get_db(); 120 | 121 | if let Some(field) = field { 122 | let old_fetch = db.get_fetch(ctx.author().id).await?; 123 | if let Some(mut fetch) = old_fetch { 124 | fetch.info.remove(&field); 125 | db.set_fetch(ctx.author().id, fetch.info, Some(Utc::now())).await?; 126 | } 127 | ctx.say_success(format!("Successfully cleared your {}", field)).await?; 128 | } else { 129 | db.set_fetch(ctx.author().id, HashMap::new(), Some(Utc::now())).await?; 130 | ctx.say_success("Successfully cleared your fetch data!").await?; 131 | } 132 | Ok(()) 133 | } 134 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/help.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude::Message; 2 | use robbb_util::embeds; 3 | use serenity::builder::CreateEmbed; 4 | 5 | use crate::checks; 6 | 7 | use super::*; 8 | 9 | /// Show this list 10 | #[poise::command(slash_command, guild_only, prefix_command)] 11 | pub async fn help( 12 | ctx: Ctx<'_>, 13 | #[description = "The command to get help for."] 14 | #[autocomplete = "poise::builtins::autocomplete_command"] 15 | command: Option, 16 | ) -> Res<()> { 17 | let mut commands = ctx 18 | .framework() 19 | .options() 20 | .commands 21 | .iter() 22 | .filter(|x| !x.hide_in_help && (x.slash_action.is_some() || x.prefix_action.is_some())); 23 | 24 | if let Some(desired_command) = command { 25 | let command = commands 26 | .find(|c| c.name == desired_command.as_str() || c.aliases.contains(&desired_command)) 27 | .user_error(&format!("Unknown command `{desired_command}`"))?; 28 | reply_help_single(ctx, command).await?; 29 | } else { 30 | let permission_level = 31 | checks::get_permission_level(ctx.serenity_context(), ctx.author()).await?; 32 | let available_commands: Vec<_> = commands 33 | .filter(|command| { 34 | command 35 | .custom_data 36 | .downcast_ref::() 37 | .map_or(true, |meta| permission_level >= meta.perms) 38 | }) 39 | .collect(); 40 | 41 | reply_help_full(ctx, &available_commands).await?; 42 | } 43 | Ok(()) 44 | } 45 | 46 | async fn reply_help_single(ctx: Ctx<'_>, command: &Command) -> Res { 47 | let mut embed = CreateEmbed::default().title(format!("Help for {}", command.name)); 48 | if let Some(desc) = &command.help_text { 49 | embed = embed.description(desc); 50 | } else if let Some(help) = &command.description { 51 | embed = embed.description(help); 52 | } 53 | 54 | if !command.subcommands.is_empty() { 55 | let subcommands_text = command 56 | .subcommands 57 | .iter() 58 | .map(|subcommand| { 59 | if let Some(usage) = &subcommand.description { 60 | format!("**/{} {}** - ``{} ``", command.name, subcommand.name, usage) 61 | } else { 62 | format!("**/{} {}**", command.name, subcommand.name) 63 | } 64 | }) 65 | .join("\n"); 66 | 67 | embed = embed.field("Subcommands", subcommands_text, false) 68 | } 69 | 70 | let handle = ctx.reply_embed_ephemeral(embed).await?; 71 | Ok(handle.message().await?.into_owned()) 72 | } 73 | 74 | async fn reply_help_full(ctx: Ctx<'_>, commands: &[&Command]) -> Res<()> { 75 | let fields = commands.iter().map(|command| { 76 | let name = if command.slash_action.is_some() { 77 | format!("**/{}**", command.name) 78 | } else { 79 | format!("**!{}**", command.name) 80 | }; 81 | let description = command.description.as_deref().unwrap_or("No description").to_string(); 82 | (name, description) 83 | }); 84 | 85 | embeds::PaginatedEmbed::create_from_fields( 86 | "Help".to_string(), 87 | fields, 88 | embeds::base_embed(&ctx), 89 | ) 90 | .await 91 | .reply_to(ctx, true) 92 | .await?; 93 | Ok(()) 94 | } 95 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/highlights.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude::CreateEmbed; 2 | 3 | use super::*; 4 | use crate::checks::{self, PermissionLevel}; 5 | 6 | /// Get notified when someone mentions a word you care about. 7 | #[poise::command( 8 | slash_command, 9 | rename = "highlight", 10 | aliases("highlights", "hl"), 11 | subcommands("highlights_add", "highlights_list", "highlights_clear", "highlights_remove",) 12 | )] 13 | pub async fn highlights(_: Ctx<'_>) -> Res<()> { 14 | Ok(()) 15 | } 16 | 17 | /// Add a new highlight 18 | #[poise::command(slash_command, guild_only, rename = "add")] 19 | pub async fn highlights_add( 20 | ctx: Ctx<'_>, 21 | #[description = "The word you want to be notified about"] trigger: String, 22 | ) -> Res<()> { 23 | if trigger.len() < 3 { 24 | abort_with!("Highlight has to be longer than 2 characters"); 25 | } 26 | 27 | let db = ctx.get_db(); 28 | let max_highlight_cnt = 29 | match checks::get_permission_level(ctx.serenity_context(), ctx.author()).await? { 30 | PermissionLevel::Mod => 20, 31 | _ => 4, 32 | }; 33 | 34 | let highlights = db.get_highlights().await?; 35 | let highlights_by_user_cnt = highlights.triggers_for_user(ctx.author().id).count(); 36 | 37 | if highlights_by_user_cnt >= max_highlight_cnt { 38 | abort_with!(UserErr::new(format!( 39 | "Sorry, you can only watch a maximum of {} highlights", 40 | max_highlight_cnt 41 | ))); 42 | } 43 | 44 | ctx.author() 45 | .id 46 | .create_dm_channel(&ctx.serenity_context()) 47 | .await 48 | .user_error("Couldn't open a DM to you - do you have me blocked?")? 49 | .send_message( 50 | &ctx.serenity_context(), 51 | CreateEmbed::default() 52 | .title("Test to see if you can receive DMs") 53 | .description(format!( 54 | "If everything went ok, you'll be notified whenever someone says `{trigger}`", 55 | )) 56 | .into_create_message(), 57 | ) 58 | .await 59 | .user_error("Couldn't send you a DM :/\nDo you allow DMs from server members?")?; 60 | 61 | db.set_highlight(ctx.author().id, trigger.clone()).await.user_error( 62 | "Couldn't add highlight, something went wrong (highlight might already be present)", 63 | )?; 64 | 65 | ctx.say_success(format!("You will be notified whenever someone says {trigger}")).await?; 66 | 67 | Ok(()) 68 | } 69 | 70 | /// List all of your highlights 71 | #[poise::command(slash_command, guild_only, rename = "list")] 72 | pub async fn highlights_list(ctx: Ctx<'_>) -> Res<()> { 73 | let db = ctx.get_db(); 74 | let highlights = db.get_highlights().await?; 75 | 76 | let highlights_list = highlights.triggers_for_user(ctx.author().id).join("\n"); 77 | 78 | if highlights_list.is_empty() { 79 | abort_with!("You don't seem to have set any highlights"); 80 | } else { 81 | ctx.reply_embed_ephemeral_builder(|e| { 82 | e.title("Your highlights").description(highlights_list) 83 | }) 84 | .await?; 85 | } 86 | Ok(()) 87 | } 88 | 89 | /// Remove a highlight 90 | #[poise::command(slash_command, guild_only, rename = "remove")] 91 | pub async fn highlights_remove( 92 | ctx: Ctx<'_>, 93 | #[autocomplete = "autocomplete_highlights"] 94 | #[description = "Which highlight do you want to remove"] 95 | trigger: String, 96 | ) -> Res<()> { 97 | let db = ctx.get_db(); 98 | db.remove_highlight(ctx.author().id, trigger.clone()) 99 | .await 100 | .user_error("Failed to remove the highlight.")?; 101 | ctx.say_success(format!("You will no longer be notified when someone says '{}'", trigger)) 102 | .await?; 103 | Ok(()) 104 | } 105 | 106 | /// Remove all of your highlights 107 | #[poise::command(slash_command, guild_only, rename = "clear")] 108 | pub async fn highlights_clear(ctx: Ctx<'_>) -> Res<()> { 109 | let db = ctx.get_db(); 110 | db.rm_highlights_of(ctx.author().id).await?; 111 | ctx.say_success("Your highlights have been successfully cleared.").await?; 112 | Ok(()) 113 | } 114 | 115 | async fn autocomplete_highlights(ctx: Ctx<'_>, partial: &str) -> Vec { 116 | let db = ctx.get_db(); 117 | if let Ok(highlights) = db.get_highlights().await { 118 | highlights 119 | .triggers_for_user(ctx.author().id) 120 | .filter(|x| x.contains(partial)) 121 | .map(|x| x.to_string()) 122 | .collect_vec() 123 | } else { 124 | Vec::new() 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/info.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use poise::serenity_prelude::{CreateEmbed, Mentionable, User}; 3 | use robbb_util::embeds; 4 | 5 | use crate::checks::check_is_moderator; 6 | 7 | use super::*; 8 | 9 | /// Get general information about any member 10 | #[poise::command(guild_only, context_menu_command = "Info")] 11 | pub async fn menu_info(ctx: Ctx<'_>, user: User) -> Res<()> { 12 | let guild = ctx.guild().context("Not in a guild")?.to_owned(); 13 | let member = guild.member(ctx.serenity_context(), &user).await?; 14 | let embed = if check_is_moderator(ctx).await? { 15 | make_mod_info_embed(ctx, member.as_ref()).await? 16 | } else { 17 | make_info_embed(ctx, member.as_ref()).await 18 | }; 19 | ctx.reply_embed_ephemeral(embed).await?; 20 | Ok(()) 21 | } 22 | 23 | /// Get general information about any member 24 | #[poise::command(slash_command, guild_only)] 25 | pub async fn info(ctx: Ctx<'_>, #[description = "User"] user: Option) -> Res<()> { 26 | let user = member_or_self(ctx, user).await?; 27 | ctx.reply_embed(make_info_embed(ctx, &user).await).await?; 28 | Ok(()) 29 | } 30 | 31 | /// Get general information and some moderation specific data about any member 32 | #[poise::command( 33 | slash_command, 34 | guild_only, 35 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 36 | )] 37 | pub async fn modinfo(ctx: Ctx<'_>, #[description = "User"] user: Member) -> Res<()> { 38 | ctx.reply_embed_ephemeral(make_mod_info_embed(ctx, &user).await?).await?; 39 | Ok(()) 40 | } 41 | 42 | async fn make_info_embed(ctx: Ctx<'_>, member: &Member) -> CreateEmbed { 43 | let created_at = member.user.created_at(); 44 | let color = member.colour(ctx.serenity_context()); 45 | let mut e = embeds::base_embed(&ctx) 46 | .title(member.user.tag()) 47 | .thumbnail(member.user.face()) 48 | .color_opt(color) 49 | .field("ID/Snowflake", member.user.id.to_string(), false) 50 | .field("Account creation date", util::format_date_detailed(*created_at), false) 51 | .field_opt("Join Date", member.joined_at.map(|x| util::format_date_detailed(*x)), false); 52 | 53 | if !member.roles.is_empty() { 54 | e = e.field("Roles", member.roles.iter().map(|x| x.mention()).join(" "), false); 55 | } 56 | e 57 | } 58 | 59 | async fn make_mod_info_embed(ctx: Ctx<'_>, member: &Member) -> Res { 60 | let db = ctx.get_db(); 61 | let note_counts = db.count_all_mod_actions(member.user.id).await?; 62 | let embed_content = note_counts 63 | .iter() 64 | .filter(|(_, count)| **count > 0) 65 | .map(|(note_type, count)| format!("**{}s**: {}", note_type, count)) 66 | .join("\n"); 67 | 68 | Ok(make_info_embed(ctx, member).await.description(embed_content)) 69 | } 70 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/kick.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use chrono::Utc; 3 | use poise::serenity_prelude::User; 4 | use serenity::{all::GuildId, builder::CreateEmbed, client}; 5 | 6 | use crate::modlog; 7 | 8 | use super::*; 9 | 10 | /// Kick a user from the server 11 | #[poise::command( 12 | slash_command, 13 | guild_only, 14 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 15 | )] 16 | pub async fn kick( 17 | ctx: Ctx<'_>, 18 | #[description = "Who is the criminal?"] 19 | #[rename = "criminal"] 20 | user: User, 21 | #[description = "What did they do?"] 22 | #[rest] 23 | reason: String, 24 | ) -> Res<()> { 25 | let db = ctx.get_db(); 26 | let guild = ctx.guild().context("Failed to fetch guild")?.clone(); 27 | do_kick(ctx.serenity_context(), guild.id, &user, &reason).await?; 28 | 29 | let success_msg = ctx 30 | .say_success_mod_action(format!("{} has been kicked from the server", user.id.mention())) 31 | .await?; 32 | let success_msg = success_msg.message().await?; 33 | 34 | db.add_mod_action( 35 | ctx.author().id, 36 | user.id, 37 | reason.to_string(), 38 | Utc::now(), 39 | success_msg.link(), 40 | robbb_db::mod_action::ModActionKind::Kick, 41 | ) 42 | .await?; 43 | 44 | modlog::log_kick(ctx, &success_msg, user, &reason).await; 45 | 46 | Ok(()) 47 | } 48 | 49 | pub async fn do_kick(ctx: &client::Context, guild: GuildId, user: &User, reason: &str) -> Res<()> { 50 | let _ = user 51 | .dm( 52 | &ctx, 53 | CreateEmbed::default() 54 | .title("You were kicked") 55 | .field("Reason", reason, false) 56 | .into_create_message(), 57 | ) 58 | .await; 59 | guild.kick_with_reason(&ctx, user, reason).await?; 60 | Ok(()) 61 | } 62 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use poise::serenity_prelude::{Guild, Mentionable, UserId}; 3 | use poise::serenity_prelude::{Member, Permissions}; 4 | use poise::Command; 5 | use robbb_util::abort_with; 6 | use robbb_util::extensions::*; 7 | use robbb_util::prelude::*; 8 | use robbb_util::util; 9 | 10 | pub mod errors; 11 | pub use errors::*; 12 | 13 | use crate::checks::PermissionLevel; 14 | 15 | pub mod attachment_hack; 16 | pub mod ban; 17 | pub mod blocklist; 18 | pub mod emojistats; 19 | pub mod fetch; 20 | pub mod help; 21 | pub mod highlights; 22 | pub mod info; 23 | pub mod kick; 24 | pub mod modping; 25 | pub mod move_users; 26 | pub mod mute; 27 | pub mod note; 28 | pub mod pfp; 29 | pub mod poll; 30 | pub mod purge; 31 | pub mod role; 32 | pub mod small; 33 | pub mod tag; 34 | pub mod top; 35 | pub mod unban; 36 | pub mod warn; 37 | 38 | pub fn all_commands() -> Vec> { 39 | let mut all_commands = vec![ 40 | // General 41 | pfp::pfp(), 42 | info::info(), 43 | help::help(), 44 | role::role(), 45 | poll::poll(), 46 | tag::tag(), 47 | tag::taglist(), 48 | modping::modping(), 49 | highlights::highlights(), 50 | small::latency(), 51 | small::uptime(), 52 | small::repo(), 53 | small::invite(), 54 | small::description(), 55 | small::git(), 56 | small::dotfiles(), 57 | small::version(), 58 | fetch::fetch(), 59 | fetch::set_fetch(), 60 | top::top(), 61 | move_users::move_users(), 62 | // Mod-only 63 | info::modinfo(), 64 | tag::settag(), 65 | small::restart(), 66 | small::say(), 67 | warn::warn(), 68 | ban::ban(), 69 | kick::kick(), 70 | ban::ban_many(), 71 | unban::unban(), 72 | emojistats::emojistats(), 73 | blocklist::blocklist(), 74 | note::note(), 75 | mute::mute(), 76 | purge::purge(), 77 | small::manage_commands(), 78 | //attachment_hack::gather_attachments(), 79 | // context menu 80 | info::menu_info(), 81 | ban::menu_ban(), 82 | warn::menu_warn(), 83 | mute::menu_mute(), 84 | ]; 85 | 86 | poise::framework::set_qualified_names(&mut all_commands); 87 | 88 | for command in all_commands.iter_mut() { 89 | preprocess_command(command); 90 | } 91 | 92 | all_commands 93 | } 94 | 95 | pub fn preprocess_command(command: &mut Command) { 96 | let meta = command.custom_data.downcast_ref::(); 97 | let perms = meta.map(|m| m.perms).unwrap_or(PermissionLevel::User); 98 | match perms { 99 | PermissionLevel::Mod => { 100 | command.checks.push(|ctx| Box::pin(crate::checks::check_is_moderator(ctx))) 101 | } 102 | PermissionLevel::Helper => { 103 | command.checks.push(|ctx| Box::pin(crate::checks::check_is_helper_or_mod(ctx))) 104 | } 105 | PermissionLevel::User => {} 106 | }; 107 | command.default_member_permissions = match perms { 108 | PermissionLevel::Mod | PermissionLevel::Helper => Permissions::ADMINISTRATOR, 109 | PermissionLevel::User => Permissions::USE_APPLICATION_COMMANDS, 110 | }; 111 | command.category = Some(command.category.clone().unwrap_or(match perms { 112 | PermissionLevel::Mod | PermissionLevel::Helper => "Moderation".to_string(), 113 | PermissionLevel::User => "Member".to_string(), 114 | })); 115 | 116 | for subcommand in command.subcommands.iter_mut() { 117 | preprocess_command(subcommand); 118 | } 119 | } 120 | 121 | pub static SELECTION_EMOJI: [&str; 19] = [ 122 | "1️⃣", 123 | "2️⃣", 124 | "3️⃣", 125 | "4️⃣", 126 | "5️⃣", 127 | "6️⃣", 128 | "7️⃣", 129 | "8️⃣", 130 | "9️⃣", 131 | "🔟", 132 | "\u{1f1e6}", 133 | "\u{1f1e7}", 134 | "\u{1f1e8}", 135 | "\u{1f1e9}", 136 | "\u{1f1f0}", 137 | "\u{1f1f1}", 138 | "\u{1f1f2}", 139 | "\u{1f1f3}", 140 | "\u{1f1f4}", 141 | ]; 142 | 143 | pub async fn member_or_self(ctx: Ctx<'_>, member: Option) -> Res { 144 | if let Some(member) = member { 145 | Ok(member) 146 | } else { 147 | Ok(ctx.author_member().await.user_error("failed to fetch message author")?.into_owned()) 148 | } 149 | } 150 | 151 | pub struct CmdMeta { 152 | perms: PermissionLevel, 153 | } 154 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/modping.rs: -------------------------------------------------------------------------------- 1 | use poise::CreateReply; 2 | 3 | use super::*; 4 | 5 | /// Ping all online moderators. Do not abuse! 6 | #[poise::command(slash_command, guild_only)] 7 | pub async fn modping( 8 | ctx: Ctx<'_>, 9 | #[description = "Why are you modpinging?"] 10 | #[rest] 11 | reason: String, 12 | ) -> Res<()> { 13 | use poise::serenity_prelude::OnlineStatus::*; 14 | let config = ctx.get_config(); 15 | let guild = ctx.guild().user_error("not in a guild")?.to_owned(); 16 | 17 | let online_staff = guild 18 | .members_with_status(Online) 19 | .chain(guild.members_with_status(Idle)) 20 | .chain(guild.members_with_status(DoNotDisturb)) 21 | .filter(|member| { 22 | member.roles.contains(&config.role_mod) || member.roles.contains(&config.role_helper) 23 | }) 24 | .collect_vec(); 25 | 26 | let contains_moderators = 27 | online_staff.iter().any(|member| member.roles.contains(&config.role_mod)); 28 | 29 | let online_mentions = online_staff.iter().map(|m| m.mention()).join(", "); 30 | let staff_to_ping = if contains_moderators { 31 | online_mentions 32 | } else { 33 | config.role_mod.mention().to_string() + ", " + &online_mentions 34 | }; 35 | 36 | ctx.send(CreateReply::default().content(format!( 37 | "{} pinged staff {staff_to_ping} for reason {reason}", 38 | ctx.author().mention() 39 | ))) 40 | .await?; 41 | 42 | Ok(()) 43 | } 44 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/move_users.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use poise::serenity_prelude::{Channel, ChannelId, CreateEmbed, Message}; 4 | 5 | use robbb_util::{abort_with, embeds}; 6 | use serenity::builder::{CreateMessage, EditMessage}; 7 | 8 | use super::*; 9 | 10 | /// Move a conversation to a different channel. 11 | #[poise::command(slash_command, guild_only, rename = "move")] 12 | pub async fn move_users( 13 | ctx: Ctx<'_>, 14 | #[description = "Channel to move to"] target_channel: Channel, 15 | #[description = "Users to move"] 16 | #[rest] 17 | users: Option, 18 | ) -> Res<()> { 19 | let target_channel = target_channel.id(); 20 | let config = ctx.get_config(); 21 | let users = users.unwrap_or_default(); 22 | 23 | if target_channel == ctx.channel_id() { 24 | abort_with!("You're already here!") 25 | } else if target_channel == config.channel_showcase 26 | || target_channel == config.channel_feedback 27 | || target_channel == config.channel_announcements 28 | || target_channel == config.channel_rules 29 | { 30 | abort_with!("I won't move you there"); 31 | } 32 | 33 | let mentions = 34 | users.split(' ').filter_map(|x| Some(x.trim().parse::().ok()?.mention())).join(" "); 35 | 36 | if target_channel == config.channel_tech_support { 37 | Ok(send_ask_in_tech_support(ctx, target_channel, mentions).await?) 38 | } else { 39 | Ok(send_move(ctx, target_channel, mentions).await?) 40 | } 41 | } 42 | 43 | async fn send_ask_in_tech_support( 44 | ctx: Ctx<'_>, 45 | target_channel: ChannelId, 46 | mentions: String, 47 | ) -> Res<()> { 48 | let police_emote = ctx 49 | .data() 50 | .up_emotes 51 | .read() 52 | .as_ref() 53 | .map(|emotes| emotes.police.to_string()) 54 | .unwrap_or_default(); 55 | 56 | ctx.reply_embed_builder(|e| { 57 | e.author_user(ctx.author()).description(indoc::formatdoc!( 58 | "{police}{police}**Please {}, ask your question in {}**{police}{police}", 59 | mentions, 60 | target_channel.mention(), 61 | police = police_emote, 62 | )) 63 | }) 64 | .await?; 65 | Ok(()) 66 | } 67 | 68 | async fn send_move(ctx: Ctx<'_>, target_channel: ChannelId, mentions: String) -> Res<()> { 69 | // we put this in a function so we can easily generate a version that contains the link back to the context 70 | // and one that doesn't yet. 71 | // Because slash commands aren't messages, we need to first send a message that we can then link to. 72 | // Because we want two-way links, we need to edit one of the messages to edit in the link later on. 73 | async fn make_continuation_embed<'a>( 74 | ctx: Ctx<'_>, 75 | continuation_msg: Option>, 76 | ) -> CreateEmbed { 77 | embeds::base_embed(&ctx).author_user(ctx.author()).description(indoc::formatdoc!( 78 | "Continuation from {} 79 | [Conversation]({})", 80 | ctx.channel_id().mention(), 81 | continuation_msg.map(|x| x.link()).unwrap_or_default(), 82 | )) 83 | } 84 | 85 | let mut continuation_msg = { 86 | let continuation_embed = make_continuation_embed(ctx, None).await; 87 | let msg = CreateMessage::default().content(mentions).embed(continuation_embed); 88 | target_channel.send_message(&ctx.serenity_context(), msg).await? 89 | }; 90 | 91 | continuation_msg.guild_id = ctx.guild_id(); 92 | let police_emote = ctx 93 | .data() 94 | .up_emotes 95 | .read() 96 | .as_ref() 97 | .map(|emotes| emotes.police.to_string()) 98 | .unwrap_or_default(); 99 | 100 | let move_message = ctx 101 | .reply_embed_builder(|e| { 102 | e.author_user(ctx.author()).description(indoc::formatdoc!( 103 | "{police}{police}**MOVE THIS CONVERSATION!**{police}{police} 104 | Continued at {}: [Conversation]({}) 105 | Please continue your conversation **there**!", 106 | target_channel.mention(), 107 | continuation_msg.link(), 108 | police = police_emote, 109 | )) 110 | }) 111 | .await?; 112 | let move_message = move_message.message().await?; 113 | 114 | let new_continuation_embed = make_continuation_embed(ctx, Some(move_message)).await; 115 | continuation_msg 116 | .edit(&ctx.serenity_context(), EditMessage::default().embed(new_continuation_embed)) 117 | .await?; 118 | Ok(()) 119 | } 120 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/mute.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use chrono::Utc; 3 | use poise::serenity_prelude::User; 4 | use robbb_db::mod_action::ModActionKind; 5 | use serenity::client; 6 | 7 | use crate::modlog; 8 | 9 | use super::*; 10 | 11 | const TIMEOUT_MAX_DAYS: i64 = 28; 12 | 13 | #[derive(poise::Modal)] 14 | #[name = "Mute"] 15 | struct MuteModal { 16 | duration: String, 17 | #[paragraph] 18 | reason: Option, 19 | } 20 | 21 | #[poise::command( 22 | guild_only, 23 | context_menu_command = "Mute", 24 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 25 | )] 26 | pub async fn menu_mute(app_ctx: AppCtx<'_>, user: User) -> Res<()> { 27 | let guild = app_ctx.guild().context("Not in a guild")?.to_owned(); 28 | let member = guild.member(&app_ctx.serenity_context(), user.id).await?; 29 | 30 | let response: Option = poise::execute_modal(app_ctx, None, None).await?; 31 | if let Some(response) = response { 32 | let duration = 33 | response.duration.parse::().user_error("Invalid duration")?; 34 | do_mute(app_ctx.into(), member.as_ref(), duration, response.reason).await?; 35 | } else { 36 | Ctx::Application(app_ctx).say_error("Cancelled").await?; 37 | } 38 | Ok(()) 39 | } 40 | 41 | /// Mute a user for a given amount of time. 42 | #[poise::command( 43 | slash_command, 44 | guild_only, 45 | custom_data = "CmdMeta { perms: PermissionLevel::Helper }" 46 | )] 47 | pub async fn mute( 48 | ctx: Ctx<'_>, 49 | #[description = "User"] user: Member, 50 | #[description = "Duration of the mute"] duration: humantime::Duration, 51 | #[description = "Reason"] 52 | #[rest] 53 | reason: Option, 54 | ) -> Res<()> { 55 | do_mute(ctx, &user, duration, reason).await?; 56 | Ok(()) 57 | } 58 | 59 | /// Run a mute from a command or context menu 60 | async fn do_mute( 61 | ctx: Ctx<'_>, 62 | member: &Member, 63 | duration: humantime::Duration, 64 | reason: Option, 65 | ) -> Res<()> { 66 | let police = ctx.get_up_emotes().map(|x| x.police.to_string()).unwrap_or_default(); 67 | let success_msg = ctx 68 | .say(format!( 69 | "{police}{police} Muting {} for {}. {police}{police}{}", 70 | member.mention(), 71 | duration, 72 | reason.as_ref().map(|x| format!("\nReason: {}", x)).unwrap_or_default() 73 | )) 74 | .await?; 75 | let success_msg = success_msg.message().await?; 76 | 77 | apply_mute( 78 | ctx.serenity_context(), 79 | ctx.author().id, 80 | member.clone(), 81 | *duration, 82 | reason.clone(), 83 | success_msg.link(), 84 | ) 85 | .await?; 86 | 87 | modlog::log_mute(&ctx, &success_msg, &member.user, duration, reason).await; 88 | Ok(()) 89 | } 90 | 91 | /// mute the user and add the mute-entry to the database. 92 | pub async fn apply_mute( 93 | ctx: &client::Context, 94 | moderator: UserId, 95 | mut member: Member, 96 | duration: std::time::Duration, 97 | reason: Option, 98 | context: String, 99 | ) -> anyhow::Result<()> { 100 | let db = ctx.get_db().await; 101 | 102 | let start_time = Utc::now(); 103 | let end_time = start_time + chrono::Duration::from_std(duration).unwrap(); 104 | 105 | // Ensure only one active mute per member 106 | db.remove_active_mutes(member.user.id).await?; 107 | 108 | db.add_mod_action( 109 | moderator, 110 | member.user.id, 111 | reason.unwrap_or_else(|| "no reason".to_string()), 112 | start_time, 113 | context, 114 | ModActionKind::Mute { end_time, active: true }, 115 | ) 116 | .await?; 117 | 118 | // TODORW possibly make this actually work for longer timeouts, via re-adding the timeout 119 | // Also set a discord timeout when possible 120 | let latest_possible_timeout = Utc::now() 121 | .checked_add_signed(chrono::Duration::days(TIMEOUT_MAX_DAYS)) 122 | .context("Overflow calculating max date")? 123 | .date_naive(); 124 | 125 | if end_time.date_naive() <= latest_possible_timeout { 126 | member.disable_communication_until_datetime(&ctx, end_time.into()).await?; 127 | } 128 | 129 | set_mute_role(ctx, member).await?; 130 | Ok(()) 131 | } 132 | 133 | /// Adds the mute role to the user, but does _not_ add any database entry. 134 | /// This should only be used if we know that an active database entry for the mute already exists, 135 | /// or else we run the risk of accidentally muting someone forever. 136 | pub async fn set_mute_role(ctx: &client::Context, member: Member) -> anyhow::Result<()> { 137 | let config = ctx.get_config().await; 138 | member.add_role(&ctx, config.role_mute).await?; 139 | Ok(()) 140 | } 141 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/note.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use chrono::Utc; 3 | use poise::{ 4 | serenity_prelude::{Mentionable, User}, 5 | Modal, 6 | }; 7 | use robbb_db::mod_action::ModActionType; 8 | use robbb_util::embeds; 9 | 10 | use crate::modlog; 11 | 12 | use super::*; 13 | 14 | /// Write a note about a user. 15 | #[poise::command( 16 | slash_command, 17 | guild_only, 18 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 19 | subcommands("note_add", "note_list", "note_delete", "note_edit") 20 | )] 21 | pub async fn note(_ctx: Ctx<'_>) -> Res<()> { 22 | Ok(()) 23 | } 24 | 25 | /// Write a note about a user. 26 | #[poise::command( 27 | slash_command, 28 | guild_only, 29 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 30 | rename = "add" 31 | )] 32 | pub async fn note_add( 33 | ctx: Ctx<'_>, 34 | #[description = "User"] user: User, 35 | #[rest] 36 | #[description = "The note"] 37 | content: String, 38 | ) -> Res<()> { 39 | let db = ctx.get_db(); 40 | 41 | let success_msg = ctx.say_success("Noting...").await?; 42 | let success_msg = success_msg.message().await?; 43 | 44 | db.add_mod_action( 45 | ctx.author().id, 46 | user.id, 47 | content.to_string(), 48 | Utc::now(), 49 | success_msg.link(), 50 | robbb_db::mod_action::ModActionKind::ManualNote, 51 | ) 52 | .await?; 53 | 54 | modlog::log_note(ctx, &user, &content).await; 55 | Ok(()) 56 | } 57 | 58 | /// Remove a specific mod action 59 | #[poise::command( 60 | slash_command, 61 | guild_only, 62 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 63 | rename = "delete" 64 | )] 65 | pub async fn note_delete( 66 | ctx: Ctx<'_>, 67 | #[description = "The user"] user: User, 68 | #[description = "Id of the mod action"] id: i64, 69 | ) -> Res<()> { 70 | let db = ctx.get_db(); 71 | let succeeded = db.remove_mod_action(user.id, id).await?; 72 | if succeeded { 73 | ctx.say_success_mod_action("Successfully removed the entry!").await?; 74 | } else { 75 | ctx.say_error("No action with that id and user").await?; 76 | } 77 | Ok(()) 78 | } 79 | 80 | /// Edit a mod action 81 | #[poise::command( 82 | slash_command, 83 | guild_only, 84 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 85 | rename = "edit" 86 | )] 87 | pub async fn note_edit( 88 | app_ctx: AppCtx<'_>, 89 | #[description = "Id of the mod action"] id: i64, 90 | ) -> Res<()> { 91 | let ctx = Ctx::Application(app_ctx); 92 | let db = ctx.get_db(); 93 | let action = db.get_mod_action(id).await?; 94 | 95 | #[derive(Modal)] 96 | #[name = "Edit"] 97 | struct NoteEditModal { 98 | #[paragraph] 99 | reason: String, 100 | } 101 | 102 | let NoteEditModal { reason } = 103 | NoteEditModal::execute_with_defaults(app_ctx, NoteEditModal { reason: action.reason }) 104 | .await? 105 | .context("Modal timed out")?; 106 | let reason = reason.trim().trim_matches('\n'); 107 | 108 | db.edit_mod_action_reason(action.id, ctx.author().id, reason.to_string()).await?; 109 | ctx.say_success_mod_action(format!( 110 | "Successfully edited {}'s entry {}", 111 | action.user.mention(), 112 | action.id 113 | )) 114 | .await?; 115 | Ok(()) 116 | } 117 | 118 | /// Read notes about a user. 119 | #[poise::command( 120 | slash_command, 121 | guild_only, 122 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 123 | rename = "list" 124 | )] 125 | pub async fn note_list( 126 | ctx: Ctx<'_>, 127 | #[description = "User"] user: User, 128 | #[description = "What kind of notes to show"] note_filter: Option, 129 | ) -> Res<()> { 130 | let db = ctx.get_db(); 131 | 132 | let mut notes = db.get_mod_actions(user.id, note_filter).await?; 133 | notes.sort_by_key(|x| std::cmp::Reverse(x.create_date)); 134 | 135 | let fields = notes.iter().map(|note| { 136 | let context_link = note 137 | .context 138 | .clone() 139 | .map(|link| format!(" - [(context)]({})", link)) 140 | .unwrap_or_default(); 141 | ( 142 | format!( 143 | "[{}] {} - {} ", 144 | note.id, 145 | note.kind.to_action_type(), 146 | util::format_date_ago(note.create_date.unwrap_or_else(Utc::now)) 147 | ), 148 | format!("{} - {}{}", note.reason, note.moderator.mention(), context_link), 149 | ) 150 | }); 151 | 152 | let base_embed = embeds::base_embed(&ctx) 153 | .description(format!("{} notes about {}", notes.len(), user.mention())) 154 | .author_user(&user); 155 | 156 | embeds::PaginatedEmbed::create_from_fields("Notes".to_string(), fields, base_embed) 157 | .await 158 | .reply_to(ctx, false) 159 | .await?; 160 | 161 | Ok(()) 162 | } 163 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/pfp.rs: -------------------------------------------------------------------------------- 1 | use robbb_util::embeds; 2 | 3 | use super::*; 4 | 5 | /// Show the profile picture of a user. 6 | #[poise::command(slash_command, guild_only)] 7 | pub async fn pfp(ctx: Ctx<'_>, #[description = "User"] user: Option) -> Res<()> { 8 | let member = member_or_self(ctx, user).await?; 9 | let server_pfp = member.face(); 10 | let user_pfp = member.user.face(); 11 | let mut embeds = Vec::new(); 12 | 13 | if user_pfp != server_pfp { 14 | embeds.push( 15 | embeds::base_embed(&ctx) 16 | .title(format!("{}'s Server Profile Picture", member.user.tag())) 17 | .image(member.face()), 18 | ); 19 | } 20 | 21 | embeds.push( 22 | embeds::base_embed(&ctx) 23 | .title(format!("{}'s User Profile Picture", member.user.tag())) 24 | .image(member.user.face()), 25 | ); 26 | 27 | embeds::PaginatedEmbed::create(embeds, embeds::base_embed(&ctx)) 28 | .await 29 | .reply_to(ctx, false) 30 | .await?; 31 | Ok(()) 32 | } 33 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/poll.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use anyhow::Context; 3 | use poise::{serenity_prelude::ReactionType, CreateReply, Modal}; 4 | use regex::Regex; 5 | use serenity::builder::{CreateEmbed, CreateEmbedFooter}; 6 | 7 | lazy_static::lazy_static! { 8 | static ref POLL_OPTION_START_OF_LINE_PATTERN: Regex = Regex::new(r"^\s*-|^\s*\d\.|^\s*\*").unwrap(); 9 | } 10 | 11 | /// Get people to vote on your question 12 | #[poise::command(slash_command, guild_only, subcommands("poll_vote", "poll_multi"))] 13 | pub async fn poll(_ctx: Ctx<'_>) -> Res<()> { 14 | Ok(()) 15 | } 16 | 17 | /// Get people to vote on your yes/no question 18 | #[poise::command(slash_command, guild_only, rename = "vote")] 19 | pub async fn poll_vote( 20 | ctx: Ctx<'_>, 21 | #[description = "A yes/no question"] question: String, 22 | ) -> Res<()> { 23 | if question.len() > 255 { 24 | abort_with!("The question is too long :(") 25 | } 26 | 27 | let poll_msg = ctx 28 | .reply_embed_builder(|e| { 29 | e.author_user(ctx.author()) 30 | .title("Poll") 31 | .description(question.clone()) 32 | .footer_str(format!("From: {}", ctx.author().tag())) 33 | }) 34 | .await?; 35 | 36 | let poll_msg = poll_msg.message().await?; 37 | poll_msg.react(&ctx.serenity_context(), ReactionType::Unicode("✅".to_string())).await?; 38 | poll_msg.react(&ctx.serenity_context(), ReactionType::Unicode("🤷".to_string())).await?; 39 | poll_msg.react(&ctx.serenity_context(), ReactionType::Unicode("❎".to_string())).await?; 40 | 41 | let config = ctx.get_config(); 42 | if ctx.channel_id() == config.channel_mod_polls { 43 | poll_msg 44 | .create_thread( 45 | ctx.serenity_context(), 46 | util::thread_title_from_text(&question).unwrap_or_else(|_| "Poll".to_string()), 47 | ) 48 | .await?; 49 | } 50 | Ok(()) 51 | } 52 | 53 | #[derive(Debug, Modal)] 54 | #[name = "Set up a poll"] 55 | struct MultiPollModal { 56 | #[name = "Title"] 57 | #[min_length = 2] 58 | #[max_length = 100] 59 | #[placeholder = "Which color has the best personality?"] 60 | title: String, 61 | #[name = "Options"] 62 | #[placeholder = "- Red\n- Green\n- Blue\n- Yellow-ish Turquoise"] 63 | #[paragraph] 64 | options: String, 65 | } 66 | 67 | /// Have others select one of many options. 68 | #[poise::command(slash_command, guild_only, rename = "multi")] 69 | pub async fn poll_multi(app_ctx: AppCtx<'_>) -> Res<()> { 70 | let ctx = poise::Context::Application(app_ctx); 71 | 72 | let modal_result = MultiPollModal::execute(app_ctx).await?.context("Modal timed out")?; 73 | 74 | let options_lines = modal_result.options.lines().collect_vec(); 75 | 76 | if options_lines.len() > SELECTION_EMOJI.len() || options_lines.len() < 2 { 77 | abort_with!(UserErr::new(format!( 78 | "There must be between 2 and {} options", 79 | SELECTION_EMOJI.len() 80 | ))) 81 | } 82 | 83 | let options_lines = options_lines 84 | .into_iter() 85 | .map(|line| POLL_OPTION_START_OF_LINE_PATTERN.replace(line, "").to_string()); 86 | 87 | let options = SELECTION_EMOJI.iter().zip(options_lines).collect_vec(); 88 | 89 | let poll_msg = ctx 90 | .send(CreateReply::default().embed({ 91 | let mut e = CreateEmbed::default().title("Poll").description(&modal_result.title); 92 | for (emoji, option) in options.iter() { 93 | e = e.field(format!("Option {}", emoji), option, false); 94 | } 95 | e.footer(CreateEmbedFooter::new(format!("from: {}", ctx.author().tag()))) 96 | })) 97 | .await?; 98 | let poll_msg = poll_msg.message().await?; 99 | 100 | for (emoji, _) in options.into_iter() { 101 | poll_msg.react(&ctx.serenity_context(), ReactionType::Unicode(emoji.to_string())).await?; 102 | } 103 | poll_msg.react(&ctx.serenity_context(), ReactionType::Unicode("🤷".to_string())).await?; 104 | 105 | let config = ctx.get_config(); 106 | if ctx.channel_id() == config.channel_mod_polls { 107 | poll_msg 108 | .create_thread( 109 | ctx.serenity_context(), 110 | util::thread_title_from_text(&modal_result.title) 111 | .unwrap_or_else(|_| "Poll".to_string()), 112 | ) 113 | .await?; 114 | } 115 | Ok(()) 116 | } 117 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/purge.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use chrono::Utc; 3 | use robbb_util::embeds; 4 | use serenity::builder::{EditMessage, GetMessages}; 5 | 6 | use super::*; 7 | 8 | /// the maximal amount of messages that we can fetch at all 9 | const MAX_BULK_DELETE_CNT: usize = 100; 10 | /// discord does not let us bulk-delete messages older than 14 days 11 | const MAX_BULK_DELETE_AGO_SECS: i64 = 60 * 60 * 24 * 14; 12 | 13 | /// Delete recent messages of a user. Cannot delete messages older than 14 days. 14 | #[poise::command( 15 | slash_command, 16 | guild_only, 17 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 18 | )] 19 | pub async fn purge( 20 | ctx: Ctx<'_>, 21 | #[description = "User id of the bad guy"] user: UserId, 22 | #[description = "How far back should we delete?"] duration: Option, 23 | #[min = 1] 24 | #[max = 100] 25 | #[description = "How many messages should we delete?"] 26 | count: Option, 27 | ) -> Res<()> { 28 | let channel = ctx.guild_channel().await.context("Not inside a guild")?; 29 | let now_timestamp = Utc::now().timestamp(); 30 | let count = count.unwrap_or(MAX_BULK_DELETE_CNT); 31 | let too_old_timestamp = now_timestamp - MAX_BULK_DELETE_AGO_SECS; 32 | 33 | let response_msg = 34 | ctx.reply_embed_builder(|e| e.description("Purging their messages...")).await?; 35 | let mut response_msg = response_msg.message().await?; 36 | 37 | let _working = ctx.defer_or_broadcast().await?; 38 | 39 | let recent_messages = channel 40 | .messages( 41 | &ctx.serenity_context(), 42 | GetMessages::default().limit(100).before(response_msg.id), 43 | ) 44 | .await? 45 | .into_iter() 46 | .filter(|msg| msg.author.id == user) 47 | .take_while(|msg| { 48 | let msg_timestamp = msg.timestamp.timestamp(); 49 | msg_timestamp > too_old_timestamp 50 | && duration.map_or(true, |d| msg_timestamp > now_timestamp - (d.as_secs() as i64)) 51 | }) 52 | .take(count) 53 | .collect_vec(); 54 | 55 | channel.delete_messages(&ctx.serenity_context(), &recent_messages).await?; 56 | 57 | let success_embed = embeds::make_success_mod_action_embed( 58 | ctx.serenity_context(), 59 | &format!("Successfully deleted {} messages", recent_messages.len()), 60 | ) 61 | .await; 62 | response_msg 63 | .to_mut() 64 | .edit(&ctx.serenity_context(), EditMessage::default().embed(success_embed)) 65 | .await?; 66 | Ok(()) 67 | } 68 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/role.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use poise::serenity_prelude::ComponentInteractionDataKind; 3 | use poise::CreateReply; 4 | use robbb_util::embeds; 5 | use serenity::{ 6 | all::RoleId, 7 | builder::{ 8 | CreateActionRow, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, 9 | CreateSelectMenu, CreateSelectMenuKind, CreateSelectMenuOption, 10 | }, 11 | }; 12 | 13 | use super::*; 14 | 15 | /// Select a role. The role can be selected in a popup. 16 | #[poise::command(slash_command, guild_only)] 17 | pub async fn role(ctx: Ctx<'_>) -> Res<()> { 18 | const NONE_VALUE: &str = "NONE"; 19 | let config = ctx.get_config(); 20 | 21 | let guild = ctx.guild().context("Not in a guild")?.to_owned(); 22 | let available_roles = std::iter::once(("None".to_string(), NONE_VALUE.to_string())).chain( 23 | config 24 | .roles_color 25 | .iter() 26 | .filter_map(|r| guild.roles.get(r)) 27 | .map(|r| (r.name.to_string(), r.id.get().to_string())), 28 | ); 29 | let interaction_custom_id = format!("{}-role", ctx.id()); 30 | 31 | let handle = ctx 32 | .send({ 33 | let embed = CreateEmbed::default() 34 | .title("Available roles") 35 | .description(config.roles_color.iter().map(|r| r.mention()).join(" ")); 36 | let options = 37 | available_roles.map(|(name, id)| CreateSelectMenuOption::new(name, id)).collect(); 38 | let menu = CreateSelectMenu::new( 39 | &interaction_custom_id, 40 | CreateSelectMenuKind::String { options }, 41 | ) 42 | .min_values(1) 43 | .max_values(1); 44 | CreateReply::default() 45 | .embed(embed) 46 | .components(vec![CreateActionRow::SelectMenu(menu)]) 47 | .ephemeral(true) 48 | }) 49 | .await?; 50 | let mut roles_msg = handle.message().await?; 51 | 52 | if let Some(interaction) = roles_msg 53 | .to_mut() 54 | .await_component_interactions(ctx.serenity_context()) 55 | .author_id(ctx.author().id) 56 | .timeout(std::time::Duration::from_secs(30)) 57 | .custom_ids(vec![interaction_custom_id]) 58 | .await 59 | { 60 | interaction 61 | .create_response( 62 | &ctx, 63 | CreateInteractionResponse::UpdateMessage( 64 | CreateInteractionResponseMessage::default() 65 | .embed(embeds::base_embed(&ctx).description("Updating roles...")) 66 | .components(vec![]), 67 | ), 68 | ) 69 | .await 70 | .context("Failed to create interactionresponse")?; 71 | let selected: String = match &interaction.data.kind { 72 | ComponentInteractionDataKind::StringSelect { values } => { 73 | values.first().context("Nothing selected")?.to_string() 74 | } 75 | _ => anyhow::bail!("Wrong interaction kind returned"), 76 | }; 77 | tracing::debug!("Got /role interaction response, selected {selected}"); 78 | 79 | let mut member = ctx.author_member().await.user_error("Not a member")?; 80 | tracing::debug!("Got member data for /role invoker"); 81 | let current_color_roles = member.roles.iter().filter(|x| config.roles_color.contains(x)); 82 | for role in current_color_roles { 83 | member.remove_role(&ctx.serenity_context(), role).await?; 84 | } 85 | tracing::debug!("Removed roles of user"); 86 | 87 | let response_embed = if selected == NONE_VALUE { 88 | embeds::make_success_embed(ctx.serenity_context(), "Success! Removed your colorrole") 89 | .await 90 | } else { 91 | let role_id = selected.parse::().context("Invalid role")?; 92 | member.to_mut().add_role(&ctx.serenity_context(), role_id).await?; 93 | tracing::debug!("added role {} to {}", role_id, member.user.tag()); 94 | 95 | embeds::make_success_embed( 96 | ctx.serenity_context(), 97 | &format!("Success! You're now {}", role_id.mention()), 98 | ) 99 | .await 100 | }; 101 | 102 | handle 103 | .edit(ctx, CreateReply::default().embed(response_embed)) 104 | .await 105 | .context("Failed to edit message")?; 106 | } else { 107 | tracing::debug!("Role selection timed out"); 108 | let timed_out_embed = 109 | embeds::make_error_embed(ctx.serenity_context(), "No role chosen").await; 110 | handle 111 | .edit(ctx, CreateReply::default().embed(timed_out_embed).components(vec![])) 112 | .await 113 | .context("Failed to send time out message")?; 114 | } 115 | 116 | Ok(()) 117 | } 118 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/tag.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use chrono::Utc; 3 | use poise::Modal; 4 | use robbb_util::cdn_hack; 5 | use tracing_futures::Instrument; 6 | 7 | use super::*; 8 | 9 | /// Get the text stored in a tag 10 | #[poise::command(slash_command, guild_only)] 11 | pub async fn tag( 12 | ctx: Ctx<'_>, 13 | #[description = "The tag to show"] 14 | #[autocomplete = "tag_autocomplete_existing"] 15 | #[rename = "tag"] 16 | tag_name: String, 17 | ) -> Res<()> { 18 | let db = ctx.get_db(); 19 | 20 | let tag = db.get_tag(&tag_name).await?.user_error("No tag with this name exists")?; 21 | 22 | let moderator = tag.moderator.to_user(&ctx.serenity_context()).await?; 23 | 24 | let content = 25 | cdn_hack::resolve_cdn_links_in_string(ctx.serenity_context(), &tag.content).await?; 26 | if util::validate_url(&content) { 27 | ctx.say(content).await?; 28 | } else { 29 | ctx.reply_embed_builder(|e| { 30 | e.title(&tag.name) 31 | .description(content) 32 | .footer_str(format!("Written by {}", moderator.tag())) 33 | .timestamp_opt(tag.create_date) 34 | }) 35 | .await?; 36 | } 37 | 38 | Ok(()) 39 | } 40 | 41 | /// Get the names of all tags 42 | #[poise::command(slash_command, guild_only, rename = "taglist")] 43 | pub async fn taglist(ctx: Ctx<'_>) -> Res<()> { 44 | let db = ctx.get_db(); 45 | 46 | let tags = db.list_tags().await?; 47 | ctx.reply_embed_builder(|e| e.title("Tags").description(tags.join(", "))).await?; 48 | Ok(()) 49 | } 50 | 51 | /// Manage tags 52 | #[poise::command( 53 | slash_command, 54 | guild_only, 55 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 56 | subcommands("tag_set", "tag_delete") 57 | )] 58 | pub async fn settag(_ctx: Ctx<'_>) -> Res<()> { 59 | Ok(()) 60 | } 61 | 62 | /// Delete a tag 63 | #[poise::command( 64 | slash_command, 65 | guild_only, 66 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 67 | rename = "delete" 68 | )] 69 | pub async fn tag_delete( 70 | ctx: Ctx<'_>, 71 | #[rename = "name"] 72 | #[description = "Name of the tag"] 73 | #[autocomplete = "tag_autocomplete_existing"] 74 | tag_name: String, 75 | ) -> Res<()> { 76 | let db = ctx.get_db(); 77 | db.delete_tag(tag_name).await?; 78 | ctx.say_success("Succesfully removed!").await?; 79 | Ok(()) 80 | } 81 | 82 | #[derive(Debug, poise::Modal)] 83 | #[name = "Tag"] 84 | struct TagModal { 85 | #[name = "Content"] 86 | #[placeholder = "Content of your tag"] 87 | #[paragraph] 88 | content: String, 89 | } 90 | 91 | /// Save a new tag or update an old one. 92 | #[poise::command( 93 | slash_command, 94 | guild_only, 95 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }", 96 | rename = "set" 97 | )] 98 | pub async fn tag_set( 99 | app_ctx: AppCtx<'_>, 100 | #[rename = "name"] 101 | #[description = "The name of the tag"] 102 | #[autocomplete = "tag_autocomplete"] 103 | tag_name: String, 104 | ) -> Res<()> { 105 | let ctx = Ctx::Application(app_ctx); 106 | let db = ctx.get_db(); 107 | 108 | let existing_tag = db.get_tag(&tag_name).await?; 109 | // Content to pre-fill into the modal text field 110 | let default_content = existing_tag.map(|x| x.content).unwrap_or_default(); 111 | 112 | let result = TagModal::execute_with_defaults(app_ctx, TagModal { content: default_content }) 113 | .instrument(tracing::info_span!("wait for modal response")) 114 | .await? 115 | .context("Modal timed out")?; 116 | 117 | let content = cdn_hack::persist_cdn_links_in_string( 118 | ctx.serenity_context(), 119 | &result.content, 120 | serde_json::json!({"kind": "tag", "tag_name": tag_name}), 121 | ) 122 | .await?; 123 | db.set_tag(ctx.author().id, tag_name, content, true, Some(Utc::now())).await?; 124 | ctx.say_success("Succesfully set!").await?; 125 | Ok(()) 126 | } 127 | 128 | /// Autocomplete all tags, but also provide whatever the user has already typed as one of the options. 129 | /// Used in /tag set, to provide completion for edits, but also allow adding new tags 130 | async fn tag_autocomplete(ctx: Ctx<'_>, partial: &str) -> impl Iterator { 131 | let last = if partial.is_empty() { vec![] } else { vec![partial.to_string()] }; 132 | tag_autocomplete_existing(ctx, partial).await.chain(last).dedup() // when the partial fully matches a value, we otherwise get a duplicate 133 | } 134 | 135 | /// Autocomplete all tags 136 | async fn tag_autocomplete_existing(ctx: Ctx<'_>, partial: &str) -> impl Iterator { 137 | let db = ctx.get_db(); 138 | let tags = db.list_tags().await.unwrap_or_default(); 139 | let partial = partial.to_ascii_lowercase(); 140 | tags.into_iter().filter(move |tag| tag.to_ascii_lowercase().starts_with(&partial)) 141 | } 142 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/unban.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use serenity::all::User; 3 | 4 | use crate::modlog; 5 | 6 | use super::*; 7 | 8 | /// Unban a user. 9 | #[poise::command( 10 | slash_command, 11 | guild_only, 12 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 13 | )] 14 | pub async fn unban( 15 | ctx: Ctx<'_>, 16 | #[description = "ID of the user you want to unban"] user: User, 17 | ) -> Res<()> { 18 | let guild = ctx.guild().context("Failed to load guild")?.to_owned(); 19 | guild.unban(&ctx.serenity_context(), user.id).await.with_user_error(|e| e.to_string())?; 20 | 21 | ctx.say_success(format!("Succesfully deyote {}", user.id.mention())).await?; 22 | 23 | modlog::log_unban(ctx, user).await; 24 | 25 | Ok(()) 26 | } 27 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/commands/warn.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use poise::serenity_prelude::User; 3 | use robbb_db::mod_action::{ModActionKind, ModActionType}; 4 | 5 | use crate::modlog; 6 | 7 | use super::*; 8 | 9 | #[derive(poise::Modal)] 10 | #[name = "Warn"] 11 | struct WarnModal { 12 | #[paragraph] 13 | reason: String, 14 | } 15 | 16 | #[poise::command( 17 | guild_only, 18 | context_menu_command = "Warn", 19 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 20 | )] 21 | pub async fn menu_warn(app_ctx: AppCtx<'_>, user: User) -> Res<()> { 22 | let response: Option = poise::execute_modal(app_ctx, None, None).await?; 23 | if let Some(response) = response { 24 | do_warn(app_ctx.into(), user, response.reason).await?; 25 | } else { 26 | Ctx::Application(app_ctx).say_error("Cancelled").await?; 27 | } 28 | Ok(()) 29 | } 30 | 31 | /// Warn a user 32 | #[poise::command( 33 | slash_command, 34 | guild_only, 35 | custom_data = "CmdMeta { perms: PermissionLevel::Mod }" 36 | )] 37 | pub async fn warn( 38 | ctx: Ctx<'_>, 39 | #[description = "Who is the criminal?"] 40 | #[rename = "criminal"] 41 | user: User, 42 | #[description = "What did they do?"] 43 | #[rest] 44 | reason: String, 45 | ) -> Res<()> { 46 | do_warn(ctx, user, reason).await?; 47 | Ok(()) 48 | } 49 | 50 | async fn do_warn(ctx: Ctx<'_>, user: User, reason: String) -> Res<()> { 51 | let db = ctx.get_db(); 52 | let warn_count = db.count_mod_actions(user.id, ModActionType::Warn).await?; 53 | 54 | let police = ctx.get_up_emotes().map(|x| x.police.to_string()).unwrap_or_default(); 55 | 56 | let success_msg = ctx 57 | .say(format!( 58 | "{police}{police} Warning {} for the {} time. {police}{police}\n**Reason: **{reason}", 59 | user.mention(), 60 | util::format_count(warn_count + 1), 61 | )) 62 | .await?; 63 | let success_msg = success_msg.message().await?; 64 | 65 | db.add_mod_action( 66 | ctx.author().id, 67 | user.id, 68 | reason.to_string(), 69 | Utc::now(), 70 | success_msg.link(), 71 | ModActionKind::Warn, 72 | ) 73 | .await?; 74 | 75 | modlog::log_warn(&ctx, &success_msg, user, warn_count + 1, &reason).await; 76 | Ok(()) 77 | } 78 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::bool_to_int_with_if)] 2 | pub mod checks; 3 | pub mod commands; 4 | pub mod modlog; 5 | -------------------------------------------------------------------------------- /crates/robbb_commands/src/modlog.rs: -------------------------------------------------------------------------------- 1 | use itertools::Itertools; 2 | use poise::serenity_prelude::Message; 3 | use robbb_db::db::mute::Mute; 4 | use robbb_util::{ 5 | extensions::{ClientContextExt, CreateEmbedExt, MessageExt, UserExt}, 6 | prelude::Ctx, 7 | util, 8 | }; 9 | use serenity::{client, model::prelude::User, prelude::Mentionable}; 10 | 11 | pub async fn log_note(ctx: Ctx<'_>, user: &User, note_content: &str) { 12 | ctx.serenity_context() 13 | .log_bot_action(|e| { 14 | e.title("Note") 15 | .author_user(ctx.author()) 16 | .thumbnail(user.face()) 17 | .description(format!( 18 | "{} took a note about {}", 19 | ctx.author().id.mention(), 20 | user.mention_and_tag(), 21 | )) 22 | .field("Note", note_content, false) 23 | }) 24 | .await; 25 | } 26 | pub async fn log_warn( 27 | ctx: &Ctx<'_>, 28 | context_msg: &Message, 29 | user: User, 30 | warn_count: i32, 31 | reason: &str, 32 | ) { 33 | ctx.serenity_context() 34 | .log_bot_action(|e| { 35 | e.title("Warn") 36 | .thumbnail(user.face()) 37 | .author_user(ctx.author()) 38 | .description(format!( 39 | "{} was warned by {} _({} warn)_\n{}", 40 | user.mention_and_tag(), 41 | ctx.author().id.mention(), 42 | util::format_count(warn_count), 43 | context_msg.to_context_link(), 44 | )) 45 | .field("Reason", reason, false) 46 | }) 47 | .await; 48 | } 49 | 50 | pub async fn log_kick(ctx: Ctx<'_>, context_msg: &Message, user: User, reason: &str) { 51 | ctx.serenity_context() 52 | .log_bot_action(|e| { 53 | e.title("Kick") 54 | .thumbnail(user.face()) 55 | .author_user(ctx.author()) 56 | .description(format!( 57 | "User {} was kicked by {}\n{}", 58 | user.mention_and_tag(), 59 | ctx.author().id.mention(), 60 | context_msg.to_context_link() 61 | )) 62 | .field("Reason", reason, false) 63 | }) 64 | .await; 65 | } 66 | 67 | pub async fn log_ban(ctx: Ctx<'_>, context_msg: &Message, successful_bans: &[User], reason: &str) { 68 | ctx.serenity_context() 69 | .log_bot_action(|e| { 70 | e.title("Ban") 71 | .author_user(ctx.author()) 72 | .description(format!( 73 | "yote user(s):\n{}\n{}", 74 | successful_bans.iter().map(|x| format!("- {}", x.mention_and_tag())).join("\n"), 75 | context_msg.to_context_link(), 76 | )) 77 | .field("Reason", reason, false) 78 | }) 79 | .await; 80 | } 81 | 82 | pub async fn log_unban(ctx: Ctx<'_>, user: User) { 83 | ctx.serenity_context() 84 | .log_bot_action(|e| { 85 | e.title("Unban") 86 | .author_user(ctx.author()) 87 | .thumbnail(user.face()) 88 | .description(format!("{} has been deyote", user.mention_and_tag())) 89 | }) 90 | .await; 91 | } 92 | 93 | pub async fn log_mute( 94 | ctx: &Ctx<'_>, 95 | context_msg: &Message, 96 | user: &User, 97 | duration: humantime::Duration, 98 | reason: Option, 99 | ) { 100 | let end_time = chrono::Duration::from_std(duration.into()) 101 | .ok() 102 | .and_then(|duration| chrono::Utc::now().checked_add_signed(duration)) 103 | .map(util::format_date_detailed); 104 | 105 | ctx.serenity_context() 106 | .log_bot_action(|e| { 107 | let mut e = e 108 | .title("Mute") 109 | .author_user(ctx.author()) 110 | .thumbnail(user.face()) 111 | .description(format!( 112 | "User {} ({}) was muted by {}\n{}", 113 | user.id.mention(), 114 | user.tag(), 115 | ctx.author().id.mention(), 116 | context_msg.to_context_link(), 117 | )) 118 | .field("Duration", format!("{}", duration), false); 119 | if let Some(end_time) = end_time { 120 | e = e.field("End", end_time, false); 121 | } 122 | if let Some(reason) = reason { 123 | e = e.field("Reason", reason, false); 124 | } 125 | e 126 | }) 127 | .await; 128 | } 129 | 130 | pub async fn log_mute_for_spamming( 131 | ctx: &client::Context, 132 | spam_msg: &Message, 133 | duration: std::time::Duration, 134 | ) { 135 | ctx.log_bot_action(|e| { 136 | e.title("Automute") 137 | .thumbnail(spam_msg.author.face()) 138 | .description(format!( 139 | "User {} was muted for spamming\n{}", 140 | spam_msg.author.mention_and_tag(), 141 | spam_msg.to_context_link(), 142 | )) 143 | .field("Duration", humantime::Duration::from(duration).to_string(), false) 144 | }) 145 | .await; 146 | } 147 | 148 | pub async fn log_user_mute_ended(ctx: &client::Context, mute: &Mute) { 149 | let user = mute.user.to_user(&ctx).await; 150 | ctx.log_bot_action(|e| { 151 | let e = e.title("Mute ended"); 152 | if let Ok(user) = user { 153 | e.description(format!("{} is now unmuted", user.mention_and_tag())) 154 | .thumbnail(user.face()) 155 | } else { 156 | e.description(format!("{} is now unmuted", mute.user.mention())) 157 | } 158 | }) 159 | .await; 160 | } 161 | -------------------------------------------------------------------------------- /crates/robbb_db/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "robbb_db" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | serenity.workspace = true 8 | poise.workspace = true 9 | thiserror = "1.0.59" 10 | sqlx = { version = "0.7.4", features = ["runtime-tokio-rustls", "sqlite", "chrono"] } 11 | anyhow = "1.0.82" 12 | chrono = "0.4.38" 13 | itertools = "0.11.0" 14 | regex = "1" 15 | tracing = "0.1.40" 16 | serde_json = "1.0.116" 17 | serde = "1.0.200" 18 | lazy_static = "1.4" 19 | unicase = "2.6.0" 20 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/blocklist.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use itertools::Itertools; 3 | use regex::{Regex, RegexBuilder}; 4 | use serenity::model::id::UserId; 5 | 6 | use super::Db; 7 | 8 | impl Db { 9 | pub async fn get_combined_blocklist_regex(&self) -> Result { 10 | let blocklist = self.get_blocklist().await?; 11 | if blocklist.is_empty() { 12 | Ok(Regex::new("a^").unwrap()) 13 | } else { 14 | Ok(RegexBuilder::new(&blocklist.join("|")).case_insensitive(true).build()?) 15 | } 16 | } 17 | 18 | #[tracing::instrument(skip_all)] 19 | pub async fn get_blocklist(&self) -> Result> { 20 | let mut cache = self.blocklist_cache.write().await; 21 | 22 | if let Some(cache) = cache.as_ref() { 23 | Ok(cache.clone()) 24 | } else { 25 | let rows = sqlx::query_scalar!(r#"select pattern as "pattern!" from blocked_regexes"#) 26 | .fetch_all(&self.pool) 27 | .await?; 28 | *cache = Some(rows.clone()); 29 | Ok(rows) 30 | } 31 | } 32 | 33 | pub async fn add_blocklist_entry(&self, user_id: UserId, s: &str) -> Result<()> { 34 | let user_id: i64 = user_id.into(); 35 | sqlx::query!("insert into blocked_regexes(pattern, added_by) values (?, ?)", s, user_id) 36 | .execute(&self.pool) 37 | .await?; 38 | 39 | let mut cache = self.blocklist_cache.write().await; 40 | if let Some(ref mut cache) = cache.as_mut() { 41 | cache.push(s.to_string()); 42 | } 43 | 44 | Ok(()) 45 | } 46 | 47 | pub async fn remove_blocklist_entry(&self, s: &str) -> Result<()> { 48 | sqlx::query!("delete from blocked_regexes where pattern=?", s).execute(&self.pool).await?; 49 | 50 | let mut cache = self.blocklist_cache.write().await; 51 | if let Some(ref mut cache) = cache.as_mut() { 52 | if let Some((pos, _)) = cache.iter().find_position(|x| x.as_str() == s) { 53 | cache.remove(pos); 54 | } 55 | } 56 | 57 | Ok(()) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/emoji_logging.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | 3 | use super::Db; 4 | 5 | use serenity::model::id::EmojiId; 6 | 7 | pub struct EmojiStats { 8 | pub emoji: EmojiIdentifier, 9 | pub reactions: u64, 10 | pub in_text: u64, 11 | } 12 | 13 | impl EmojiStats { 14 | pub fn new(emoji_id: EmojiIdentifier) -> EmojiStats { 15 | EmojiStats { emoji: emoji_id, reactions: 0, in_text: 0 } 16 | } 17 | } 18 | 19 | #[derive(Clone)] 20 | pub struct EmojiIdentifier { 21 | pub id: EmojiId, 22 | pub animated: bool, 23 | pub name: String, 24 | } 25 | 26 | pub enum Ordering { 27 | Ascending, 28 | Descending, 29 | } 30 | 31 | impl Db { 32 | #[tracing::instrument(skip_all)] 33 | pub async fn alter_emoji_reaction_count( 34 | &self, 35 | amount: i64, 36 | emoji: &EmojiIdentifier, 37 | ) -> Result { 38 | let emoji_str = &emoji.name; 39 | let id: i64 = emoji.id.into(); 40 | sqlx::query!("insert into emoji_stats (emoji_id, emoji_name, reaction_usage, animated) values (?1, ?2, max(0, ?3), ?4) on conflict(emoji_id) do update set reaction_usage=max(0, reaction_usage + ?3)", 41 | id, emoji_str, amount, emoji.animated) 42 | .execute(&self.pool) 43 | .await?; 44 | self.get_emoji_usage_by_id(emoji).await 45 | } 46 | 47 | #[tracing::instrument(skip_all)] 48 | pub async fn alter_emoji_text_count( 49 | &self, 50 | amount: i64, 51 | emoji: &EmojiIdentifier, 52 | ) -> Result { 53 | let id: i64 = emoji.id.into(); 54 | let emoji_str = &emoji.name; 55 | sqlx::query!("insert into emoji_stats (emoji_id, emoji_name, in_text_usage, animated) values (?1, ?2, max(0, ?3), ?4) on conflict(emoji_id) do update set in_text_usage=max(0, in_text_usage + ?3)", 56 | id, emoji_str, amount, emoji.animated) 57 | .execute(&self.pool) 58 | .await?; 59 | self.get_emoji_usage_by_id(emoji).await 60 | } 61 | 62 | #[tracing::instrument(skip_all)] 63 | pub async fn get_emoji_usage_by_id(&self, emoji: &EmojiIdentifier) -> Result { 64 | let emoji_id: i64 = emoji.id.into(); 65 | let value = sqlx::query!("select * from emoji_stats where emoji_id=?", emoji_id) 66 | .fetch_optional(&self.pool) 67 | .await?; 68 | Ok(value 69 | .map(|x| EmojiStats { 70 | emoji: EmojiIdentifier { 71 | id: EmojiId::new(x.emoji_id as u64), 72 | animated: x.animated != 0, 73 | name: x.emoji_name.unwrap(), 74 | }, 75 | in_text: x.in_text_usage as u64, 76 | reactions: x.reaction_usage as u64, 77 | }) 78 | .unwrap_or_else(|| EmojiStats::new(emoji.clone()))) 79 | } 80 | 81 | #[tracing::instrument(skip_all)] 82 | pub async fn get_emoji_usage_by_name(&self, emoji: &str) -> Result { 83 | let value = sqlx::query!("select * from emoji_stats where emoji_name=?", emoji) 84 | .fetch_optional(&self.pool) 85 | .await?; 86 | value 87 | .map(|x| EmojiStats { 88 | emoji: EmojiIdentifier { 89 | id: EmojiId::new(x.emoji_id as u64), 90 | animated: x.animated != 0, 91 | name: x.emoji_name.unwrap(), 92 | }, 93 | in_text: x.in_text_usage as u64, 94 | reactions: x.reaction_usage as u64, 95 | }) 96 | .context("Could not find emoji by that name") 97 | } 98 | 99 | #[tracing::instrument(skip_all)] 100 | pub async fn get_top_emoji_stats( 101 | &self, 102 | count: u16, 103 | ordering: Ordering, 104 | ) -> Result> { 105 | // This exists to allow generic creation of queries, as the queries are two distinct types 106 | // and cannot be used in a match without also constructing the struct 107 | macro_rules! process_emoji_stats_query { 108 | ($query:expr,$limit:tt) => {{ 109 | let records = sqlx::query!($query, $limit).fetch_all(&self.pool).await?; 110 | 111 | Ok(records 112 | .into_iter() 113 | .map(|x| EmojiStats { 114 | emoji: EmojiIdentifier { 115 | id: EmojiId::new(x.emoji_id as u64), 116 | animated: x.animated != 0, 117 | name: x.emoji_name.unwrap(), 118 | }, 119 | in_text: x.in_text_usage as u64, 120 | reactions: x.reaction_usage as u64, 121 | }) 122 | .collect()) 123 | }}; 124 | } 125 | match ordering { 126 | Ordering::Ascending => process_emoji_stats_query!( 127 | r#"select *, in_text_usage + reaction_usage as "usage!: i32" FROM emoji_stats order by "usage!: i32" ASC limit ?"#, 128 | count 129 | ), 130 | Ordering::Descending => process_emoji_stats_query!( 131 | r#"select *, in_text_usage + reaction_usage as "usage!: i32" FROM emoji_stats order by "usage!: i32" DESC limit ?"#, 132 | count 133 | ), 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/fetch.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::{Context, Result}; 4 | 5 | use chrono::{DateTime, Utc}; 6 | use serenity::model::id::UserId; 7 | 8 | use super::fetch_field::{FetchField, FETCH_KEY_ORDER}; 9 | use super::Db; 10 | 11 | #[derive(Debug)] 12 | pub struct Fetch { 13 | pub user: UserId, 14 | pub info: HashMap, 15 | pub create_date: Option>, 16 | } 17 | 18 | impl Fetch { 19 | pub fn get_values_ordered(mut self) -> Vec<(FetchField, String)> { 20 | let mut entries: Vec<(FetchField, String)> = FETCH_KEY_ORDER 21 | .iter() 22 | .filter_map(|x| Some((x.clone(), self.info.remove(x)?))) 23 | .collect(); 24 | if let Some(image) = self.info.remove(&FetchField::Image) { 25 | entries.push((FetchField::Image, image)); 26 | } 27 | entries 28 | } 29 | } 30 | 31 | impl Db { 32 | #[tracing::instrument(skip_all)] 33 | pub async fn set_fetch( 34 | &self, 35 | user: UserId, 36 | info: HashMap, 37 | create_date: Option>, 38 | ) -> Result { 39 | { 40 | let user: i64 = user.into(); 41 | let info = serde_json::to_string(&info)?; 42 | 43 | sqlx::query!( 44 | "insert into fetch (usr, info, create_date) values (?1, ?2, ?3) on conflict(usr) do update set info=?2, create_date=?3", 45 | user, 46 | info, 47 | create_date, 48 | ) 49 | .execute(&self.pool) 50 | .await?; 51 | } 52 | 53 | Ok(Fetch { user, info, create_date }) 54 | } 55 | 56 | #[tracing::instrument(skip_all)] 57 | pub async fn get_fetch(&self, user: UserId) -> Result> { 58 | let user: i64 = user.into(); 59 | let value = sqlx::query!("select * from fetch where usr=?", user) 60 | .fetch_optional(&self.pool) 61 | .await?; 62 | if let Some(x) = value { 63 | let create_date = x 64 | .create_date 65 | .map(|date| chrono::DateTime::from_naive_utc_and_offset(date, chrono::Utc)); 66 | Ok(Some(Fetch { 67 | user: UserId::new(x.usr as u64), 68 | info: serde_json::from_str(&x.info).context("Failed to deserialize fetch data")?, 69 | create_date, 70 | })) 71 | } else { 72 | Ok(None) 73 | } 74 | } 75 | 76 | #[tracing::instrument(skip_all)] 77 | pub async fn update_fetch( 78 | &self, 79 | user: UserId, 80 | new_values: HashMap, 81 | ) -> Result { 82 | let mut fetch = self.get_fetch(user).await?.map(|x| x.info).unwrap_or_default(); 83 | 84 | for (key, value) in new_values { 85 | fetch.insert(key, value); 86 | } 87 | 88 | self.set_fetch(user, fetch, Some(Utc::now())).await 89 | } 90 | 91 | #[tracing::instrument(skip_all)] 92 | pub async fn get_all_fetches(&self) -> Result> { 93 | sqlx::query!("select * from fetch") 94 | .fetch_all(&self.pool) 95 | .await? 96 | .into_iter() 97 | .map(|x| { 98 | let create_date = x 99 | .create_date 100 | .map(|date| chrono::DateTime::from_naive_utc_and_offset(date, chrono::Utc)); 101 | Ok(Fetch { 102 | user: UserId::new(x.usr as u64), 103 | info: serde_json::from_str(&x.info) 104 | .context("Failed to deserialize fetch data")?, 105 | create_date, 106 | }) 107 | }) 108 | .collect::>() 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/fetch_field.rs: -------------------------------------------------------------------------------- 1 | use poise::SlashArgument; 2 | use serde::{Deserialize, Serialize}; 3 | use serenity::{ 4 | all::{CommandInteraction, CommandOptionType, ResolvedValue}, 5 | async_trait, 6 | builder::CreateCommandOption, 7 | }; 8 | use std::{fmt, str::FromStr}; 9 | 10 | #[derive(Debug, Serialize, Deserialize, Hash, PartialEq, Eq, Clone)] 11 | pub enum FetchField { 12 | Distro, 13 | Kernel, 14 | Terminal, 15 | Editor, 16 | #[serde(rename = "DE/WM")] 17 | DEWM, 18 | Bar, 19 | Resolution, 20 | #[serde(rename = "Display Protocol")] 21 | DisplayProtocol, 22 | Shell, 23 | #[serde(rename = "GTK3 Theme")] 24 | GTK3, 25 | #[serde(rename = "GTK Icon Theme")] 26 | Icons, 27 | CPU, 28 | GPU, 29 | Memory, 30 | Description, 31 | Git, 32 | Dotfiles, 33 | #[serde(rename = "image")] 34 | Image, 35 | } 36 | 37 | pub static FETCH_KEY_ORDER: &[FetchField] = &[ 38 | FetchField::Distro, 39 | FetchField::Kernel, 40 | FetchField::Terminal, 41 | FetchField::Editor, 42 | FetchField::DEWM, 43 | FetchField::Bar, 44 | FetchField::Resolution, 45 | FetchField::DisplayProtocol, 46 | FetchField::Shell, 47 | FetchField::GTK3, 48 | FetchField::Icons, 49 | FetchField::CPU, 50 | FetchField::GPU, 51 | FetchField::Memory, 52 | FetchField::Description, 53 | FetchField::Git, 54 | FetchField::Dotfiles, 55 | FetchField::Image, 56 | ]; 57 | 58 | impl fmt::Display for FetchField { 59 | fn fmt(&self, writer: &mut fmt::Formatter) -> fmt::Result { 60 | match self { 61 | FetchField::DEWM => write!(writer, "DE/WM"), 62 | FetchField::DisplayProtocol => write!(writer, "Display Protocol"), 63 | FetchField::GTK3 => write!(writer, "GTK3 Theme"), 64 | FetchField::Icons => write!(writer, "GTK Icon Theme"), 65 | FetchField::Image => write!(writer, "Image"), 66 | _ => write!(writer, "{:?}", self), 67 | } 68 | } 69 | } 70 | 71 | #[derive(Debug, thiserror::Error)] 72 | #[error("Not a valid fetch field")] 73 | pub struct FetchFieldParseError; 74 | 75 | impl std::str::FromStr for FetchField { 76 | type Err = FetchFieldParseError; 77 | fn from_str(s: &str) -> Result { 78 | match s.to_ascii_lowercase().as_str() { 79 | "distro" => Ok(Self::Distro), 80 | "kernel" => Ok(Self::Kernel), 81 | "terminal" => Ok(Self::Terminal), 82 | "editor" => Ok(Self::Editor), 83 | "dewm" | "de" | "wm" | "de/wm" => Ok(Self::DEWM), 84 | "bar" => Ok(Self::Bar), 85 | "resolution" => Ok(Self::Resolution), 86 | "display protocol" => Ok(Self::DisplayProtocol), 87 | "shell" => Ok(Self::Shell), 88 | "gtk theme" | "gtk3 theme" | "theme" | "gtk" => Ok(Self::GTK3), 89 | "icons" | "icon theme" | "gtk icon theme" => Ok(Self::Icons), 90 | "cpu" => Ok(Self::CPU), 91 | "gpu" => Ok(Self::GPU), 92 | "memory" => Ok(Self::Memory), 93 | "description" => Ok(Self::Description), 94 | "git" => Ok(Self::Git), 95 | "dotfiles" => Ok(Self::Dotfiles), 96 | "image" => Ok(Self::Image), 97 | _ => Err(FetchFieldParseError), 98 | } 99 | } 100 | } 101 | 102 | #[async_trait] 103 | impl SlashArgument for FetchField { 104 | async fn extract( 105 | _: &serenity::prelude::Context, 106 | _: &CommandInteraction, 107 | value: &ResolvedValue<'_>, 108 | ) -> Result { 109 | match value { 110 | ResolvedValue::String(s) => Ok(FetchField::from_str(s).map_err(|_e| { 111 | poise::SlashArgError::new_command_structure_mismatch("Bad argument") 112 | })?), 113 | _ => Err(poise::SlashArgError::new_command_structure_mismatch("Expected String")), 114 | } 115 | } 116 | 117 | fn create(mut builder: CreateCommandOption) -> CreateCommandOption { 118 | builder = builder.kind(CommandOptionType::String); 119 | for value in FETCH_KEY_ORDER.iter() { 120 | builder = builder.add_string_choice(value.to_string(), value.to_string()); 121 | } 122 | builder 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/highlights_forbidden_words: -------------------------------------------------------------------------------- 1 | myself 2 | our 3 | ours 4 | ourselves 5 | you 6 | your 7 | yours 8 | yourself 9 | yourselves 10 | him 11 | his 12 | himself 13 | she 14 | her 15 | hers 16 | herself 17 | its 18 | itself 19 | they 20 | them 21 | their 22 | theirs 23 | themselves 24 | what 25 | which 26 | who 27 | whom 28 | this 29 | that 30 | these 31 | those 32 | are 33 | was 34 | were 35 | been 36 | being 37 | have 38 | has 39 | had 40 | having 41 | does 42 | did 43 | doing 44 | the 45 | and 46 | but 47 | because 48 | until 49 | while 50 | for 51 | with 52 | about 53 | against 54 | between 55 | into 56 | through 57 | during 58 | before 59 | after 60 | above 61 | below 62 | from 63 | down 64 | out 65 | off 66 | over 67 | under 68 | again 69 | further 70 | then 71 | once 72 | here 73 | there 74 | when 75 | where 76 | why 77 | how 78 | all 79 | any 80 | both 81 | each 82 | few 83 | more 84 | most 85 | other 86 | some 87 | such 88 | nor 89 | not 90 | only 91 | own 92 | same 93 | than 94 | too 95 | very 96 | can 97 | will 98 | just 99 | don 100 | dont 101 | should 102 | now 103 | lol 104 | lmao 105 | kek 106 | 656117576119615489 107 | kekw 108 | 655931480505057291 109 | stare 110 | thonk 111 | fuck 112 | shit 113 | linux 114 | windows 115 | com 116 | net 117 | yes 118 | yea 119 | yeah 120 | best 121 | fine 122 | want 123 | bruh 124 | despair 125 | 833161902976270376 126 | name 127 | bad 128 | good 129 | god 130 | think 131 | probably 132 | cringe 133 | nope 134 | nah 135 | discord 136 | discordapp 137 | channels 138 | reddit 139 | google 140 | work 141 | working 142 | game 143 | games 144 | based 145 | stop 146 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/htm.rs: -------------------------------------------------------------------------------- 1 | use serenity::model::id::UserId; 2 | 3 | use crate::Db; 4 | 5 | #[derive(Debug)] 6 | pub struct HardToModerateEntry { 7 | pub user: UserId, 8 | } 9 | 10 | impl Db { 11 | #[tracing::instrument(skip_all)] 12 | pub async fn check_user_htm(&self, id: UserId) -> anyhow::Result { 13 | let id: i64 = id.into(); 14 | Ok(sqlx::query!(r#"select * from hard_to_moderate where usr=?"#, id) 15 | .fetch_optional(&self.pool) 16 | .await? 17 | .is_some()) 18 | } 19 | 20 | #[tracing::instrument(skip_all)] 21 | pub async fn add_htm(&self, id: UserId) -> anyhow::Result<()> { 22 | let id: i64 = id.into(); 23 | sqlx::query!(r#"insert or ignore into hard_to_moderate (usr) values (?)"#, id) 24 | .fetch_optional(&self.pool) 25 | .await?; 26 | Ok(()) 27 | } 28 | 29 | #[tracing::instrument(skip_all)] 30 | pub async fn remove_htm(&self, id: UserId) -> anyhow::Result<()> { 31 | let id: i64 = id.into(); 32 | sqlx::query!(r#"delete from hard_to_moderate where usr=?"#, id) 33 | .fetch_optional(&self.pool) 34 | .await?; 35 | Ok(()) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/mod.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | use std::sync::Arc; 3 | 4 | use anyhow::{bail, Context, Result}; 5 | 6 | use serenity::model::id::UserId; 7 | use serenity::prelude::RwLock; 8 | use serenity::prelude::TypeMapKey; 9 | use sqlx::SqlitePool; 10 | use std::collections::HashMap; 11 | pub mod blocklist; 12 | pub mod emoji_logging; 13 | pub mod fetch; 14 | pub mod fetch_field; 15 | pub mod highlights; 16 | pub mod htm; 17 | pub mod mod_action; 18 | pub mod mute; 19 | pub mod tag; 20 | 21 | #[derive(Debug)] 22 | pub struct Db { 23 | pool: SqlitePool, 24 | blocklist_cache: Arc>>>, 25 | highlight_cache: RwLock>, 26 | tag_name_cache: RwLock>>, 27 | } 28 | 29 | impl TypeMapKey for Db { 30 | type Value = Arc; 31 | } 32 | 33 | impl Db { 34 | pub async fn new() -> Result { 35 | let pool = SqlitePool::connect(&std::env::var("DATABASE_URL")?).await?; 36 | Ok(Self { 37 | pool, 38 | blocklist_cache: Arc::new(RwLock::new(None)), 39 | highlight_cache: RwLock::new(None), 40 | tag_name_cache: RwLock::new(None), 41 | }) 42 | } 43 | 44 | pub async fn run_migrations(&self) -> Result<()> { 45 | sqlx::migrate!("../../migrations") 46 | .run(&self.pool) 47 | .await 48 | .context("Failed to run database migrations")?; 49 | Ok(()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/mute.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{Context, Result}; 2 | use chrono::{DateTime, Utc}; 3 | use serenity::model::id::UserId; 4 | 5 | use super::Db; 6 | 7 | #[derive(Debug)] 8 | pub struct Mute { 9 | pub id: i64, 10 | pub moderator: UserId, 11 | pub user: UserId, 12 | pub reason: String, 13 | pub start_time: DateTime, 14 | pub end_time: DateTime, 15 | pub context: Option, 16 | } 17 | 18 | impl Db { 19 | #[tracing::instrument(skip_all)] 20 | pub async fn get_newly_expired_mutes(&self) -> Result> { 21 | sqlx::query!( 22 | "SELECT * from mute, mod_action 23 | WHERE mute.mod_action = mod_action.id 24 | AND cast(strftime('%s', end_time) as integer) < cast(strftime('%s', datetime('now')) as integer) 25 | AND active" 26 | ) 27 | .fetch_all(&self.pool).await? 28 | .into_iter() 29 | .map(|x| Ok(Mute { 30 | id: x.id, 31 | moderator: UserId::new(x.moderator as u64), 32 | user: UserId::new(x.usr as u64), 33 | reason: x.reason.unwrap_or_default(), 34 | start_time: DateTime::::from_naive_utc_and_offset(x.create_date.context("no create date")?, Utc), 35 | end_time: DateTime::::from_naive_utc_and_offset(x.end_time, Utc), 36 | context: x.context, 37 | })) 38 | .collect::>() 39 | } 40 | 41 | #[tracing::instrument(skip_all)] 42 | pub async fn get_mutes(&self, user_id: UserId) -> Result> { 43 | let id: i64 = user_id.into(); 44 | sqlx::query!( 45 | "select * from mute, mod_action where mute.mod_action = mod_action.id AND usr=?", 46 | id 47 | ) 48 | .fetch_all(&self.pool) 49 | .await? 50 | .into_iter() 51 | .map(|x| { 52 | Ok(Mute { 53 | id: x.id, 54 | moderator: UserId::new(x.moderator as u64), 55 | user: UserId::new(x.usr as u64), 56 | reason: x.reason.unwrap_or_default(), 57 | start_time: DateTime::::from_naive_utc_and_offset( 58 | x.create_date.context("no create date")?, 59 | Utc, 60 | ), 61 | end_time: DateTime::::from_naive_utc_and_offset(x.end_time, Utc), 62 | context: x.context, 63 | }) 64 | }) 65 | .collect::>() 66 | } 67 | 68 | #[tracing::instrument(skip_all)] 69 | pub async fn get_active_mute(&self, user_id: UserId) -> Result> { 70 | let id: i64 = user_id.into(); 71 | sqlx::query!("select * from mute, mod_action where mute.mod_action = mod_action.id AND usr=? AND active=true", id) 72 | .fetch_optional(&self.pool) 73 | .await? 74 | .map(|x| Ok(Mute { 75 | id: x.id, 76 | moderator: UserId::new(x.moderator as u64), 77 | user: UserId::new(x.usr as u64), 78 | reason: x.reason.unwrap_or_default(), 79 | start_time: DateTime::::from_naive_utc_and_offset(x.create_date.context("no create date")?, Utc), 80 | end_time: DateTime::::from_naive_utc_and_offset(x.end_time, Utc), 81 | context: x.context, 82 | })) 83 | .transpose() 84 | } 85 | 86 | #[tracing::instrument(skip_all)] 87 | pub async fn remove_active_mutes(&self, user_id: UserId) -> Result<()> { 88 | let id: i64 = user_id.into(); 89 | sqlx::query!( 90 | "update mute set active=false 91 | from mute m 92 | join mod_action on mod_action.id = m.mod_action 93 | where mod_action.usr=? and m.active=true 94 | ", 95 | id 96 | ) 97 | .execute(&self.pool) 98 | .await?; 99 | Ok(()) 100 | } 101 | 102 | #[tracing::instrument(skip_all)] 103 | pub async fn set_mute_inactive(&self, id: i64) -> Result<()> { 104 | sqlx::query!("update mute set active = false where mod_action = ?", id) 105 | .execute(&self.pool) 106 | .await?; 107 | Ok(()) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /crates/robbb_db/src/db/tag.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use chrono::{DateTime, Utc}; 3 | use serenity::model::id::UserId; 4 | 5 | use super::Db; 6 | 7 | #[derive(Debug)] 8 | pub struct Tag { 9 | pub name: String, 10 | pub moderator: UserId, 11 | pub content: String, 12 | pub official: bool, 13 | pub create_date: Option>, 14 | } 15 | 16 | impl Db { 17 | #[tracing::instrument(skip_all)] 18 | pub async fn set_tag( 19 | &self, 20 | moderator: UserId, 21 | name: String, 22 | content: String, 23 | official: bool, 24 | create_date: Option>, 25 | ) -> Result { 26 | let moderator_id: i64 = moderator.into(); 27 | sqlx::query!( 28 | "insert into tag (name, moderator, content, official, create_date) values (?, ?, ?, ?, ?) 29 | on conflict(name) do update set moderator=?, content=?, official=?, create_date=?", 30 | name, 31 | moderator_id, 32 | content, 33 | official, 34 | create_date, 35 | moderator_id, 36 | content, 37 | official, 38 | create_date, 39 | ) 40 | .execute(&self.pool) 41 | .await?; 42 | 43 | // Insert into the cache if there are already things in the cache. 44 | // If there aren't yet, then we don't care, as the cache will be filled with all values when it's read for the first time. 45 | if let Some(tag_names) = self.tag_name_cache.write().await.as_mut() { 46 | tag_names.insert(name.clone()); 47 | } 48 | 49 | Ok(Tag { name, moderator, content, official, create_date }) 50 | } 51 | 52 | #[tracing::instrument(skip_all)] 53 | pub async fn get_tag(&self, name: &str) -> Result> { 54 | Ok(sqlx::query!( 55 | r#"select name as "name!", moderator, content, official, create_date from tag where name=? COLLATE NOCASE"#, 56 | name 57 | ) 58 | .fetch_optional(&self.pool) 59 | .await? 60 | .map(|x| { 61 | let create_date = x 62 | .create_date 63 | .map(|date| chrono::DateTime::from_naive_utc_and_offset(date, chrono::Utc)); 64 | Tag { 65 | name: x.name, 66 | moderator: UserId::new(x.moderator as u64), 67 | content: x.content, 68 | official: x.official, 69 | create_date, 70 | }})) 71 | } 72 | 73 | #[tracing::instrument(skip_all)] 74 | pub async fn delete_tag(&self, name: String) -> Result<()> { 75 | sqlx::query!(r#"delete from tag where name=? COLLATE NOCASE"#, name) 76 | .execute(&self.pool) 77 | .await?; 78 | 79 | if let Some(tag_names) = self.tag_name_cache.write().await.as_mut() { 80 | tag_names.remove(&name); 81 | } 82 | 83 | Ok(()) 84 | } 85 | 86 | #[tracing::instrument(skip_all)] 87 | pub async fn list_tags(&self) -> Result> { 88 | let tag_name_cache = self.tag_name_cache.read().await; 89 | if let Some(tag_names) = tag_name_cache.as_ref() { 90 | Ok(tag_names.clone().into_iter().collect()) 91 | } else { 92 | std::mem::drop(tag_name_cache); 93 | let tag_names = sqlx::query_scalar(r#"select name as "name!" from tag"#) 94 | .fetch_all(&self.pool) 95 | .await?; 96 | 97 | let mut tag_name_cache = self.tag_name_cache.write().await; 98 | let _ = tag_name_cache.insert(tag_names.clone().into_iter().collect()); 99 | 100 | Ok(tag_names) 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /crates/robbb_db/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod db; 2 | pub use db::*; 3 | -------------------------------------------------------------------------------- /crates/robbb_util/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "robbb_util" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | serenity.workspace = true 10 | poise.workspace = true 11 | anyhow = "1.0.82" 12 | serde_json = "1.0.116" 13 | serde = "1.0.200" 14 | thiserror = "1.0.59" 15 | lazy_static = "1.4" 16 | chrono = "0.4.38" 17 | chrono-humanize = "0.2.3" 18 | itertools = "0.11.0" 19 | tokio-util = { version = "0.7.10", features = ["compat"] } 20 | tokio = { version = "1.21", features = ["macros", "fs", "rt-multi-thread"]} 21 | futures = "0.3.30" 22 | tracing = "0.1.40" 23 | reqwest = { version = "0.11" } 24 | tracing-futures = "0.2.5" 25 | rand = "0.8.5" 26 | regex = "1.10.4" 27 | url = "2.5.0" 28 | parking_lot = "0.12.2" 29 | extend = "1.2" 30 | 31 | robbb_db = { path = "../robbb_db" } 32 | -------------------------------------------------------------------------------- /crates/robbb_util/src/collect_interaction.rs: -------------------------------------------------------------------------------- 1 | use futures::{Stream, StreamExt}; 2 | use poise::serenity_prelude::{Context, Message, UserId}; 3 | use serenity::all::ComponentInteraction; 4 | 5 | pub struct UserSpecificComponentInteractionCollector { 6 | stream: T, 7 | by_user_limit: usize, 8 | } 9 | 10 | impl UserSpecificComponentInteractionCollector 11 | where 12 | T: Stream + Unpin, 13 | { 14 | pub async fn next(&mut self) -> Option { 15 | if self.by_user_limit == 0 { 16 | return None; 17 | } 18 | let interaction = self.stream.next().await?; 19 | self.by_user_limit -= 1; 20 | Some(interaction) 21 | } 22 | } 23 | 24 | /// Await component interactions to a specific [`Message`] by a specific [`UserId`], limiting the number of interactions 25 | /// that will be returned. 26 | pub fn await_component_interactions_by( 27 | ctx: &Context, 28 | message: &Message, 29 | user_id: UserId, 30 | by_user_limit: usize, 31 | timeout: std::time::Duration, 32 | ) -> UserSpecificComponentInteractionCollector> { 33 | let stream = message 34 | .await_component_interaction(ctx) 35 | .filter(move |x| x.user.id == user_id) 36 | .timeout(timeout) 37 | .stream(); 38 | UserSpecificComponentInteractionCollector { by_user_limit, stream } 39 | } 40 | -------------------------------------------------------------------------------- /crates/robbb_util/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::HashSet, path::PathBuf, sync::Arc}; 2 | 3 | use poise::serenity_prelude::{ChannelId, CreateEmbed, GuildId, RoleId}; 4 | use serenity::{all::UserId, client, prelude::TypeMapKey}; 5 | 6 | use crate::{ 7 | extensions::GuildIdExt, 8 | log_error, 9 | util::{parse_required_env_var, required_env_var}, 10 | }; 11 | 12 | #[derive(Debug)] 13 | pub struct Config { 14 | pub discord_token: String, 15 | 16 | pub owners: HashSet, 17 | 18 | pub guild: GuildId, 19 | pub role_mod: RoleId, 20 | pub role_helper: RoleId, 21 | pub role_mute: RoleId, 22 | pub role_htm: RoleId, 23 | pub roles_color: Vec, 24 | 25 | pub category_mod_private: ChannelId, 26 | pub category_modmail: ChannelId, 27 | pub channel_announcements: ChannelId, 28 | pub channel_rules: ChannelId, 29 | pub channel_showcase: ChannelId, 30 | pub channel_feedback: ChannelId, 31 | pub channel_modlog: ChannelId, 32 | pub channel_mod_bot_stuff: ChannelId, 33 | pub channel_auto_mod: ChannelId, 34 | pub channel_bot_messages: ChannelId, 35 | pub channel_bot_traffic: ChannelId, 36 | pub channel_tech_support: ChannelId, 37 | pub channel_mod_polls: ChannelId, 38 | pub channel_attachment_dump: Option, 39 | pub channel_fake_cdn: ChannelId, 40 | 41 | pub attachment_cache_path: PathBuf, 42 | pub attachment_cache_max_size: usize, 43 | 44 | pub time_started: chrono::DateTime, 45 | } 46 | 47 | impl Config { 48 | pub fn from_environment() -> anyhow::Result { 49 | Ok(Config { 50 | discord_token: required_env_var("TOKEN")?, 51 | owners: required_env_var("OWNERS")? 52 | .split(',') 53 | .map(|x| Ok(x.trim().parse()?)) 54 | .collect::>()?, 55 | 56 | guild: GuildId::new(parse_required_env_var("GUILD")?), 57 | role_mod: RoleId::new(parse_required_env_var("ROLE_MOD")?), 58 | role_helper: RoleId::new(parse_required_env_var("ROLE_HELPER")?), 59 | role_mute: RoleId::new(parse_required_env_var("ROLE_MUTE")?), 60 | role_htm: RoleId::new(parse_required_env_var("ROLE_HTM")?), 61 | roles_color: required_env_var("ROLES_COLOR")? 62 | .split(',') 63 | .map(|x| Ok(x.trim().parse()?)) 64 | .collect::>()?, 65 | category_mod_private: ChannelId::new(parse_required_env_var("CATEGORY_MOD_PRIVATE")?), 66 | category_modmail: ChannelId::new(parse_required_env_var("CATEGORY_MODMAIL")?), 67 | channel_announcements: ChannelId::new(parse_required_env_var("CHANNEL_ANNOUNCEMENTS")?), 68 | channel_rules: ChannelId::new(parse_required_env_var("CHANNEL_RULES")?), 69 | channel_showcase: ChannelId::new(parse_required_env_var("CHANNEL_SHOWCASE")?), 70 | channel_feedback: ChannelId::new(parse_required_env_var("CHANNEL_FEEDBACK")?), 71 | channel_modlog: ChannelId::new(parse_required_env_var("CHANNEL_MODLOG")?), 72 | channel_auto_mod: ChannelId::new(parse_required_env_var("CHANNEL_AUTO_MOD")?), 73 | channel_mod_bot_stuff: ChannelId::new(parse_required_env_var("CHANNEL_MOD_BOT_STUFF")?), 74 | channel_bot_messages: ChannelId::new(parse_required_env_var("CHANNEL_BOT_MESSAGES")?), 75 | channel_bot_traffic: ChannelId::new(parse_required_env_var("CHANNEL_BOT_TRAFFIC")?), 76 | channel_tech_support: ChannelId::new(parse_required_env_var("CHANNEL_TECH_SUPPORT")?), 77 | channel_mod_polls: ChannelId::new(parse_required_env_var("CHANNEL_MOD_POLLS")?), 78 | channel_attachment_dump: parse_required_env_var("CHANNEL_ATTACHMENT_DUMP") 79 | .map(ChannelId::new) 80 | .ok(), 81 | channel_fake_cdn: ChannelId::new(parse_required_env_var("CHANNEL_FAKE_CDN")?), 82 | attachment_cache_path: parse_required_env_var("ATTACHMENT_CACHE_PATH")?, 83 | attachment_cache_max_size: parse_required_env_var("ATTACHMENT_CACHE_MAX_SIZE")?, 84 | time_started: chrono::Utc::now(), 85 | }) 86 | } 87 | 88 | pub async fn log_bot_action(&self, ctx: &client::Context, build_embed: F) 89 | where 90 | F: FnOnce(CreateEmbed) -> CreateEmbed + Send + Sync, 91 | { 92 | let result = self.guild.send_embed(ctx, self.channel_modlog, build_embed).await; 93 | 94 | log_error!(result); 95 | } 96 | pub async fn log_automod_action(&self, ctx: &client::Context, build_embed: F) 97 | where 98 | F: FnOnce(CreateEmbed) -> CreateEmbed + Send + Sync, 99 | { 100 | let result = self.guild.send_embed(ctx, self.channel_auto_mod, build_embed).await; 101 | log_error!(result); 102 | } 103 | } 104 | 105 | impl TypeMapKey for Config { 106 | type Value = Arc; 107 | } 108 | -------------------------------------------------------------------------------- /crates/robbb_util/src/embeds/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | extensions::{ClientContextExt, PoiseContextExt}, 3 | prelude::Ctx, 4 | UpEmotes, 5 | }; 6 | 7 | use chrono::Utc; 8 | use serenity::{ 9 | builder::{CreateEmbed, CreateEmbedFooter}, 10 | client, 11 | }; 12 | 13 | pub mod paginated_embeds; 14 | pub use paginated_embeds::*; 15 | 16 | fn make_base_embed(emotes: Option<&UpEmotes>) -> CreateEmbed { 17 | CreateEmbed::default().timestamp(Utc::now()).footer({ 18 | let mut f = CreateEmbedFooter::new("\u{200b}"); 19 | if let Some(stare) = emotes.and_then(|x| x.random_stare()) { 20 | f = f.icon_url(stare.url()); 21 | } 22 | f 23 | }) 24 | } 25 | 26 | pub fn base_embed(ctx: &Ctx<'_>) -> CreateEmbed { 27 | make_base_embed(ctx.get_up_emotes().as_deref()) 28 | } 29 | 30 | pub async fn base_embed_ctx(ctx: &client::Context) -> CreateEmbed { 31 | make_base_embed(ctx.get_up_emotes().await.as_deref()) 32 | } 33 | 34 | pub async fn make_success_embed(ctx: &client::Context, text: &str) -> CreateEmbed { 35 | let up_emotes = ctx.get_up_emotes().await; 36 | let emote = up_emotes.as_ref().map(|x| format!(" {}", x.poggers.clone())); 37 | make_base_embed(up_emotes.as_deref()) 38 | .description(format!("{}{}", text, emote.unwrap_or_default())) 39 | .color(0xb8bb26u32) 40 | } 41 | 42 | pub async fn make_success_mod_action_embed(ctx: &client::Context, text: &str) -> CreateEmbed { 43 | let up_emotes = ctx.get_up_emotes().await; 44 | let emote = up_emotes.as_ref().map(|x| format!(" {}", x.police.clone())); 45 | make_base_embed(up_emotes.as_deref()) 46 | .description(format!("{}{}", text, emote.unwrap_or_default())) 47 | .color(0xb8bb26u32) 48 | } 49 | 50 | pub async fn make_error_embed(ctx: &client::Context, text: &str) -> CreateEmbed { 51 | let up_emotes = ctx.get_up_emotes().await; 52 | let emote = up_emotes.as_ref().map(|x| format!(" {}", x.pensibe.clone())); 53 | make_base_embed(up_emotes.as_deref()) 54 | .description(format!("{}{}", text, emote.unwrap_or_default())) 55 | .color(0xfb4934u32) 56 | } 57 | -------------------------------------------------------------------------------- /crates/robbb_util/src/embeds/paginated_embeds.rs: -------------------------------------------------------------------------------- 1 | use crate::{extensions::PoiseContextExt, prelude::Ctx, util::ellipsis_text}; 2 | 3 | use anyhow::Result; 4 | use itertools::Itertools; 5 | use poise::{serenity_prelude::CreateActionRow, CreateReply, ReplyHandle}; 6 | use serenity::{ 7 | builder::{ 8 | CreateButton, CreateEmbed, CreateInteractionResponse, CreateInteractionResponseMessage, 9 | }, 10 | collector::ComponentInteractionCollector, 11 | }; 12 | 13 | const PAGINATION_LEFT: &str = "LEFT"; 14 | const PAGINATION_RIGHT: &str = "RIGHT"; 15 | const MAX_EMBED_FIELDS: usize = 12; // discords max is 25, but that's ugly 16 | 17 | #[derive(Debug)] 18 | pub struct PaginatedEmbed { 19 | pages: Vec, 20 | base_embed: CreateEmbed, 21 | } 22 | 23 | impl PaginatedEmbed { 24 | pub async fn create( 25 | embeds: impl IntoIterator, 26 | base_embed: CreateEmbed, 27 | ) -> PaginatedEmbed { 28 | PaginatedEmbed { pages: embeds.into_iter().collect(), base_embed } 29 | } 30 | 31 | pub async fn create_from_fields( 32 | title: String, 33 | fields: impl IntoIterator, 34 | base_embed: CreateEmbed, 35 | ) -> PaginatedEmbed { 36 | let pages = fields.into_iter().chunks(MAX_EMBED_FIELDS); 37 | let pages: Vec<_> = pages.into_iter().collect(); 38 | let page_cnt = pages.len(); 39 | let pages = pages 40 | .into_iter() 41 | .enumerate() 42 | .map(|(page_idx, fields)| { 43 | let mut e = base_embed.clone(); 44 | if page_cnt < 2 { 45 | e = e.title(&title); 46 | } else { 47 | e = e.title(format!("{} ({}/{})", title, page_idx + 1, page_cnt)); 48 | } 49 | e.fields(fields.map(|(k, v)| (k, ellipsis_text(&v, 500), false)).collect_vec()) 50 | }) 51 | .collect_vec(); 52 | 53 | PaginatedEmbed { pages, base_embed } 54 | } 55 | 56 | pub async fn reply_to<'a>(&self, ctx: Ctx<'a>, ephemeral: bool) -> Result<()> { 57 | match self.pages.as_slice() { 58 | [] => { 59 | if ephemeral { 60 | ctx.reply_embed_ephemeral(self.base_embed.clone()).await?; 61 | } else { 62 | ctx.reply_embed(self.base_embed.clone()).await?; 63 | } 64 | } 65 | [page] => { 66 | if ephemeral { 67 | ctx.reply_embed_ephemeral(page.clone()).await?; 68 | } else { 69 | ctx.reply_embed(page.clone()).await?; 70 | } 71 | } 72 | pages => { 73 | let reply = CreateReply::default() 74 | .ephemeral(ephemeral) 75 | .components(vec![make_paginate_row(ctx.id(), 0, pages.len())]) 76 | .embed(self.pages.first().unwrap().clone()); 77 | let handle = ctx.send(reply).await?; 78 | handle_pagination_interactions(ctx, pages.to_vec(), &handle).await?; 79 | } 80 | } 81 | Ok(()) 82 | } 83 | } 84 | 85 | #[tracing::instrument(skip_all)] 86 | async fn handle_pagination_interactions( 87 | ctx: Ctx<'_>, 88 | pages: Vec, 89 | handle: &ReplyHandle<'_>, 90 | ) -> Result<()> { 91 | let mut current_page_idx = 0; 92 | let ctx_id = ctx.id(); 93 | 94 | while let Some(interaction) = ComponentInteractionCollector::new(ctx) 95 | .filter(move |x| x.data.custom_id.starts_with(&ctx_id.to_string())) 96 | .timeout(std::time::Duration::from_secs(30)) 97 | .author_id(ctx.author().id) 98 | .await 99 | { 100 | let direction = interaction.data.clone().custom_id; 101 | let left_id = format!("{ctx_id}{PAGINATION_LEFT}"); 102 | let right_id = format!("{ctx_id}{PAGINATION_RIGHT}"); 103 | if direction == left_id && current_page_idx > 0 { 104 | current_page_idx -= 1; 105 | } else if direction == right_id && current_page_idx < pages.len() - 1 { 106 | current_page_idx += 1; 107 | } 108 | let response_msg = CreateInteractionResponseMessage::default() 109 | .embed(pages.get(current_page_idx).unwrap().clone()) 110 | .components(vec![make_paginate_row(ctx_id, current_page_idx, pages.len())]); 111 | interaction 112 | .create_response( 113 | &ctx.serenity_context(), 114 | CreateInteractionResponse::UpdateMessage(response_msg), 115 | ) 116 | .await?; 117 | } 118 | // Once no further interactions are expected, remove the components from the message 119 | let reply = CreateReply::default() 120 | .embed(pages.get(current_page_idx).unwrap().clone()) 121 | .components(vec![]); 122 | handle.edit(ctx, reply).await?; 123 | Ok(()) 124 | } 125 | 126 | fn make_paginate_row(ctx_id: u64, page_idx: usize, page_cnt: usize) -> CreateActionRow { 127 | CreateActionRow::Buttons(vec![ 128 | CreateButton::new(format!("{ctx_id}{PAGINATION_LEFT}")).label("←").disabled(page_idx == 0), 129 | CreateButton::new(format!("{ctx_id}{PAGINATION_RIGHT}")) 130 | .label("→") 131 | .disabled(page_idx >= page_cnt - 1), 132 | ]) 133 | } 134 | -------------------------------------------------------------------------------- /crates/robbb_util/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod cdn_hack; 2 | pub mod collect_interaction; 3 | pub mod config; 4 | pub mod embeds; 5 | pub mod extensions; 6 | pub mod prelude; 7 | pub mod util; 8 | 9 | use std::{collections::HashMap, sync::Arc}; 10 | 11 | use anyhow::Context; 12 | use poise::serenity_prelude::{Emoji, GuildId}; 13 | use rand::prelude::IteratorRandom; 14 | use robbb_db::Db; 15 | use serenity::{all::EmojiId, client, prelude::TypeMapKey}; 16 | 17 | #[derive(Debug, Clone)] 18 | pub struct UpEmotes { 19 | pub pensibe: Emoji, 20 | pub police: Emoji, 21 | pub poggers: Emoji, 22 | pub stares: Vec, 23 | pub all_emoji: HashMap, 24 | } 25 | impl UpEmotes { 26 | pub fn random_stare(&self) -> Option { 27 | let mut rng = rand::thread_rng(); 28 | self.stares.iter().choose(&mut rng).cloned() 29 | } 30 | 31 | pub fn from_emojis(all_emoji: Vec) -> anyhow::Result { 32 | Ok(UpEmotes { 33 | pensibe: all_emoji 34 | .iter() 35 | .find(|x| x.name == "pensibe") 36 | .context("no pensibe emote found")? 37 | .clone(), 38 | police: all_emoji 39 | .iter() 40 | .find(|x| x.name == "police") 41 | .context("no police emote found")? 42 | .clone(), 43 | poggers: all_emoji 44 | .iter() 45 | .find(|x| x.name == "poggersphisch") 46 | .context("no poggers emote found")? 47 | .clone(), 48 | stares: all_emoji.iter().filter(|x| x.name.starts_with("stare")).cloned().collect(), 49 | all_emoji: all_emoji.into_iter().map(|x| (x.id, x)).collect(), 50 | }) 51 | } 52 | } 53 | 54 | #[tracing::instrument(skip_all)] 55 | pub async fn load_up_emotes(ctx: &client::Context, guild: GuildId) -> anyhow::Result { 56 | let all_emoji = guild.emojis(&ctx).await?; 57 | UpEmotes::from_emojis(all_emoji) 58 | } 59 | 60 | impl TypeMapKey for UpEmotes { 61 | type Value = Arc; 62 | } 63 | 64 | #[derive(Debug, Clone)] 65 | pub struct UserData { 66 | pub config: Arc, 67 | pub db: Arc, 68 | pub up_emotes: Arc>>>, 69 | } 70 | -------------------------------------------------------------------------------- /crates/robbb_util/src/prelude.rs: -------------------------------------------------------------------------------- 1 | use poise::serenity_prelude::CreateEmbed; 2 | 3 | pub use crate::UserData; 4 | 5 | pub type Error = anyhow::Error; // Box; 6 | 7 | /// Calling this Res is a temporary workaround until poise fixes the fact that it's macros rely on Result being std::result::Result... 8 | pub type Res = anyhow::Result; // std::result::Result; 9 | 10 | pub type Ctx<'a> = poise::Context<'a, UserData, Error>; 11 | pub type AppCtx<'a> = poise::ApplicationContext<'a, UserData, Error>; 12 | pub type PrefixCtx<'a> = poise::PrefixContext<'a, UserData, Error>; 13 | 14 | //pub type BoxedCreateMessageBuilder = Box< 15 | // dyn for<'a, 'b> FnOnce(&'a mut CreateMessage<'b>) -> &'a mut CreateMessage<'b> + Send + Sync, 16 | //>; 17 | pub type BoxedCreateEmbedBuilder<'a> = Box; 18 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import 2 | ( 3 | let lock = builtins.fromJSON (builtins.readFile ./flake.lock); in 4 | fetchTarball { 5 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 6 | sha256 = lock.nodes.flake-compat.locked.narHash; 7 | } 8 | ) 9 | { src = ./.; } 10 | ).defaultNix 11 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "crane": { 4 | "inputs": { 5 | "flake-compat": "flake-compat", 6 | "flake-utils": "flake-utils", 7 | "nixpkgs": [ 8 | "nixpkgs" 9 | ] 10 | }, 11 | "locked": { 12 | "lastModified": 1658371493, 13 | "narHash": "sha256-QCuzY/rHkxJx4HYDh623ctA7tnEzTIFgiIlOkP0yZ+0=", 14 | "owner": "ipetkov", 15 | "repo": "crane", 16 | "rev": "731a81056b7cef18dcb0d8116b238d3162ce76b1", 17 | "type": "github" 18 | }, 19 | "original": { 20 | "owner": "ipetkov", 21 | "repo": "crane", 22 | "type": "github" 23 | } 24 | }, 25 | "flake-compat": { 26 | "flake": false, 27 | "locked": { 28 | "lastModified": 1650374568, 29 | "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", 30 | "owner": "edolstra", 31 | "repo": "flake-compat", 32 | "rev": "b4a34015c698c7793d592d66adbab377907a2be8", 33 | "type": "github" 34 | }, 35 | "original": { 36 | "owner": "edolstra", 37 | "repo": "flake-compat", 38 | "type": "github" 39 | } 40 | }, 41 | "flake-compat_2": { 42 | "flake": false, 43 | "locked": { 44 | "lastModified": 1650374568, 45 | "narHash": "sha256-Z+s0J8/r907g149rllvwhb4pKi8Wam5ij0st8PwAh+E=", 46 | "owner": "edolstra", 47 | "repo": "flake-compat", 48 | "rev": "b4a34015c698c7793d592d66adbab377907a2be8", 49 | "type": "github" 50 | }, 51 | "original": { 52 | "owner": "edolstra", 53 | "repo": "flake-compat", 54 | "type": "github" 55 | } 56 | }, 57 | "flake-utils": { 58 | "locked": { 59 | "lastModified": 1656928814, 60 | "narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", 61 | "owner": "numtide", 62 | "repo": "flake-utils", 63 | "rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", 64 | "type": "github" 65 | }, 66 | "original": { 67 | "owner": "numtide", 68 | "repo": "flake-utils", 69 | "type": "github" 70 | } 71 | }, 72 | "flake-utils_2": { 73 | "locked": { 74 | "lastModified": 1656065134, 75 | "narHash": "sha256-oc6E6ByIw3oJaIyc67maaFcnjYOz1mMcOtHxbEf9NwQ=", 76 | "owner": "numtide", 77 | "repo": "flake-utils", 78 | "rev": "bee6a7250dd1b01844a2de7e02e4df7d8a0a206c", 79 | "type": "github" 80 | }, 81 | "original": { 82 | "owner": "numtide", 83 | "repo": "flake-utils", 84 | "type": "github" 85 | } 86 | }, 87 | "nixpkgs": { 88 | "locked": { 89 | "lastModified": 1658321858, 90 | "narHash": "sha256-2f5NzHbi1NLSYnDg6cRu25B+XRxqngicvggg+0GgKHI=", 91 | "owner": "NixOS", 92 | "repo": "nixpkgs", 93 | "rev": "26fe7618c7efbbfe28db9a52a21fb87e67ebaf06", 94 | "type": "github" 95 | }, 96 | "original": { 97 | "owner": "NixOS", 98 | "ref": "nixos-22.05", 99 | "repo": "nixpkgs", 100 | "type": "github" 101 | } 102 | }, 103 | "nixpkgs_2": { 104 | "locked": { 105 | "lastModified": 1656401090, 106 | "narHash": "sha256-bUS2nfQsvTQW2z8SK7oEFSElbmoBahOPtbXPm0AL3I4=", 107 | "owner": "NixOS", 108 | "repo": "nixpkgs", 109 | "rev": "16de63fcc54e88b9a106a603038dd5dd2feb21eb", 110 | "type": "github" 111 | }, 112 | "original": { 113 | "owner": "NixOS", 114 | "ref": "nixpkgs-unstable", 115 | "repo": "nixpkgs", 116 | "type": "github" 117 | } 118 | }, 119 | "root": { 120 | "inputs": { 121 | "crane": "crane", 122 | "flake-compat": "flake-compat_2", 123 | "nixpkgs": "nixpkgs", 124 | "rust-overlay": "rust-overlay", 125 | "utils": "utils" 126 | } 127 | }, 128 | "rust-overlay": { 129 | "inputs": { 130 | "flake-utils": "flake-utils_2", 131 | "nixpkgs": "nixpkgs_2" 132 | }, 133 | "locked": { 134 | "lastModified": 1658371912, 135 | "narHash": "sha256-e/17Hr+zWlNJlcuaNxHNGwlzqF+IJu7NWoKYFJj4kZU=", 136 | "owner": "oxalica", 137 | "repo": "rust-overlay", 138 | "rev": "5be0c1633a6f99dfefdca0aad665c941a7b769aa", 139 | "type": "github" 140 | }, 141 | "original": { 142 | "owner": "oxalica", 143 | "repo": "rust-overlay", 144 | "type": "github" 145 | } 146 | }, 147 | "utils": { 148 | "locked": { 149 | "lastModified": 1656928814, 150 | "narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", 151 | "owner": "numtide", 152 | "repo": "flake-utils", 153 | "rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", 154 | "type": "github" 155 | }, 156 | "original": { 157 | "owner": "numtide", 158 | "repo": "flake-utils", 159 | "type": "github" 160 | } 161 | } 162 | }, 163 | "root": "root", 164 | "version": 7 165 | } 166 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | 3 | description = "r/unixporn discord bot"; 4 | inputs = { 5 | crane = { 6 | url = "github:ipetkov/crane"; 7 | inputs.nixpkgs.follows = "nixpkgs"; 8 | }; 9 | nixpkgs.url = "github:NixOS/nixpkgs/nixos-22.05"; 10 | utils.url = "github:numtide/flake-utils"; 11 | flake-compat = { 12 | url = "github:edolstra/flake-compat"; 13 | flake = false; 14 | }; 15 | rust-overlay.url = "github:oxalica/rust-overlay"; 16 | }; 17 | 18 | outputs = { self, nixpkgs, utils, crane, rust-overlay, ... }: 19 | utils.lib.eachDefaultSystem (system: 20 | let 21 | pkgs = import nixpkgs { 22 | inherit system; 23 | overlays = [ rust-overlay.overlays.default ]; 24 | }; 25 | 26 | # Common Args 27 | commonArgs = { 28 | pname = "robbb"; 29 | # Will Require `--impure` to be passed to it can read that ENV VAR 30 | version = if builtins.getEnv "VERSION" != "" then 31 | builtins.getEnv "VERSION" 32 | else 33 | "0.0.1"; 34 | src = builtins.path { path = pkgs.lib.cleanSource ./.; name = "robbb"; }; 35 | nativeBuildInputs = with pkgs; [ rust-toolchain pkg-config ]; 36 | buildInputs = with pkgs; [ openssl sqlx-cli rust-analyzer sqlite ]; 37 | }; 38 | # Use the toolchain from the `rust-toolchain` file 39 | rust-toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain; 40 | craneLib = (crane.mkLib pkgs).overrideToolchain rust-toolchain; 41 | 42 | # Build Deps so We don't have to build them everytime 43 | cargoArtifacts = craneLib.buildDepsOnly commonArgs; 44 | 45 | # Run Cargo Fmt 46 | robbbFmt = craneLib.cargoFmt (commonArgs // { inherit cargoArtifacts; }); 47 | 48 | # Run Clippy (only if cargo fmt passes) 49 | robbbClippy = craneLib.cargoClippy (commonArgs // { 50 | cargoArtifacts = robbbFmt; 51 | cargoClippyExtraArgs = "-- -D warnings"; 52 | }); 53 | 54 | # Build Robb (only if all above tests pass) 55 | robbb = craneLib.buildPackage 56 | (commonArgs // { cargoArtifacts = robbbClippy; }); 57 | in { 58 | # `nix flake check` (build, fmt and clippy) 59 | checks = { inherit robbb; }; 60 | 61 | # `nix build` 62 | packages.default = robbb; 63 | 64 | # `nix run` 65 | apps.default = utils.lib.mkApp { drv = robbb; }; 66 | 67 | # `nix develop` 68 | devShells.default = pkgs.mkShell { 69 | inputsFrom = builtins.attrValues self.checks; 70 | 71 | shellHook = '' 72 | export $(cat .env) 73 | ''; 74 | # Extra inputs can be added here 75 | packages = [ pkgs.darwin.apple_sdk.frameworks.Security ] ++ commonArgs.nativeBuildInputs ++ commonArgs.buildInputs; 76 | }; 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /gen-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # generate .env file for testing up's bot 3 | # https://github.com/unixporn/Supreme-Demolition-Droid 4 | 5 | cat << EOF >&2 6 | Note: Don't forget to enable the 'Presence' & 'Server members' intents in the bot's settings 7 | Link to template: https://discord.new/zkhTrUTEbtg9 8 | Link to add bot: https://discord.com/oauth2/authorize?scope=bot&client_id= 9 | EOF 10 | # You can also export these variables in your normal environment 11 | # If variable is empty, then ask the user to type (/paste) the new contents. 12 | [ ! "$serverid" ] && printf "[input the template server's ID]: " >&2 && read -r serverid 13 | [ ! "$token" ] && printf "[input the bot's token]: " >&2 && read -r token 14 | 15 | 16 | # ${#VAR} == get length of variable 17 | [ "${#token}" -ge 50 ] && [ "$serverid" -ge 999 ] || exec \ 18 | echo "Please input/export a valid token & server ID" >&2 19 | 20 | # clean the env 21 | unset mod intern mute col start 22 | unset announcements rules feedback showcase techsupport modcat botstuff modlog botmod botlog humantrafficking polls 23 | 24 | 25 | # 26 | # roles 27 | # 28 | 29 | # curl general server info -> use jq to get the roles in an easily-parsable format 30 | roles=$(curl -s -X GET \ 31 | -H "Authorization: Bot $token" \ 32 | -H "Content-Type: application/json" \ 33 | "https://discord.com/api/v9/guilds/$serverid" \ 34 | | jq -r '.roles | sort_by(.position) | reverse | .[] | [.id, .name] | join("\t")') 35 | 36 | # do a line-by-line loop of the roles to get the variables 37 | # note: posix sh doesn't support escape codes in variables 38 | while IFS=$(printf '\t') read -r id name; do 39 | case $name in 40 | "@everyone") ;; 41 | "mods") mod=$id;; 42 | "unpaid intern") intern=$id;; 43 | "mute") mute=$id;; 44 | # colours 45 | "black") col="$id" start=1;; 46 | *) [ "$start" ] && col="$col,$id";; 47 | esac 48 | done << EOF 49 | $roles 50 | EOF 51 | 52 | 53 | # 54 | # channels 55 | # 56 | 57 | # curl channel list -> use jq to change to an easily-parsable format 58 | channels=$(curl -s -X GET \ 59 | -H "Authorization: Bot $token" \ 60 | -H "Content-Type: application/json" \ 61 | "https://discord.com/api/v9/guilds/$serverid/channels" \ 62 | | jq -r '.[] | [.id, .name] | join("\t")') 63 | 64 | # do a line-by-line loop of the channels to get the variables 65 | # note: posix sh doesn't support escape codes in variables 66 | while IFS=$(printf '\t') read -r id name; do 67 | case $name in 68 | announcements) announcements=$id;; 69 | rules) rules=$id;; 70 | server-feedback) feedback=$id;; 71 | showcase) showcase=$id;; 72 | tech-support) techsupport=$id;; 73 | /root/) modcat=$id;; 74 | polls) polls=$id;; 75 | bot-stuff) botstuff=$id;; 76 | mod-log) modlog=$id;; 77 | bot-auto-mod) botmod=$id;; 78 | bot-messages) botlog=$id;; 79 | user-log) humantrafficking=$id;; 80 | esac 81 | done << EOF 82 | $channels 83 | EOF 84 | 85 | 86 | cat << EOF 87 | 88 | export DATABASE_URL=sqlite:base.db 89 | export TOKEN=$token 90 | export GUILD=$serverid 91 | export ROLE_MOD=$mod 92 | export ROLE_HELPER=$intern 93 | export ROLE_MUTE=$mute 94 | export ROLES_COLOR=$col 95 | export CATEGORY_MOD_PRIVATE=$modcat 96 | export CHANNEL_ANNOUNCEMENTS=$announcements 97 | export CHANNEL_RULES=$rules 98 | export CHANNEL_SHOWCASE=$showcase 99 | export CHANNEL_FEEDBACK=$feedback 100 | export CHANNEL_MODLOG=$modlog 101 | export CHANNEL_AUTO_MOD=$botmod 102 | export CHANNEL_BOT_MESSAGES=$botlog 103 | export CHANNEL_MOD_BOT_STUFF=$botstuff 104 | export CHANNEL_BOT_TRAFFIC=$humantrafficking 105 | export CHANNEL_TECH_SUPPORT=$techsupport 106 | export CHANNEL_MOD_POLLS=$polls 107 | export CHANNEL_ATTACHMENT_DUMP="" 108 | export ATTACHMENT_CACHE_PATH=./cache 109 | export ATTACHMENT_CACHE_MAX_SIZE=50000000 110 | 111 | EOF 112 | -------------------------------------------------------------------------------- /migrations/20220521195821_initialize.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS mod_action ( 2 | id integer primary key asc, 3 | moderator integer not null, 4 | usr integer not null, 5 | reason text, 6 | context text, 7 | action_type integer not null, 8 | create_date datetime 9 | ); 10 | 11 | CREATE TABLE IF NOT EXISTS mute ( 12 | mod_action integer not null unique, 13 | end_time datetime not null, 14 | active boolean not null, 15 | FOREIGN KEY(mod_action) REFERENCES mod_action(id) ON DELETE CASCADE 16 | ); 17 | 18 | CREATE TABLE IF NOT EXISTS fetch ( 19 | usr integer primary key, 20 | info text not null, 21 | create_date datetime 22 | ); 23 | 24 | CREATE TABLE IF NOT EXISTS blocked_regexes ( 25 | pattern text primary key, 26 | added_by integer not null 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS tag ( 30 | name text primary key, 31 | moderator integer not null, 32 | content text not null, 33 | official boolean not null, 34 | create_date datetime 35 | ); 36 | 37 | CREATE TABLE IF NOT EXISTS highlights ( 38 | word text not null, 39 | usr integer not null, 40 | PRIMARY KEY (word, usr) 41 | ); 42 | CREATE TABLE IF NOT EXISTS emoji_stats ( 43 | emoji_id integer not null, 44 | emoji_name text, 45 | animated integer not null, 46 | in_text_usage integer not null default 0, 47 | reaction_usage integer not null default 0, 48 | PRIMARY KEY(emoji_id) 49 | ); 50 | -------------------------------------------------------------------------------- /migrations/20231120193725_Add_hard_to_moderate_table.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS hard_to_moderate ( 2 | usr integer primary key 3 | ); 4 | -------------------------------------------------------------------------------- /rust-toolchain: -------------------------------------------------------------------------------- 1 | stable 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | use_small_heuristics = "Max" 3 | -------------------------------------------------------------------------------- /shell.nix: -------------------------------------------------------------------------------- 1 | (import 2 | ( 3 | let 4 | lock = builtins.fromJSON (builtins.readFile ./flake.lock); 5 | in 6 | fetchTarball { 7 | url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz"; 8 | sha256 = lock.nodes.flake-compat.locked.narHash; 9 | } 10 | ) 11 | { 12 | src = ./.; 13 | }).shellNix 14 | --------------------------------------------------------------------------------