├── .dockerignore ├── .env ├── .envrc.example ├── .github ├── PULL_REQUEST_TEMPLATE.md ├── logo.svg ├── renovate.json └── workflows │ ├── audit.yml │ ├── cd.yml │ ├── commands.yml │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .sqlx ├── query-07ca64b10025a77a597f7d003c7bf4fdd0b1a7a9b23f9246c6dab120a91f0498.json ├── query-0b5aa446aa706d7d3b4f4e485d589f2c116d529b46e74f3440201d08b6c56bf4.json ├── query-10fd2084c314c5a357f7408746702d90c908a44c3148b03de91bcf5eaee9712e.json ├── query-14c35efbbe2e0c6c2edcec300f4f9f58257c5bf84a28f1ac2156539edc3ba6e0.json ├── query-1a7ad5f985960145009f82ab41eda810cf2f14b510e5196aaf6684d945daf017.json ├── query-2971ad0c94858b6c33d6ba863ea2e877fd574267d806ed1f7593c5cbb14ba1a2.json ├── query-2a9879a85acc57d9a732657e57494dfb60c75f544f90e0c328e8c9bc34901f8e.json ├── query-34c16baa9bad362981992f30483bf07b6af29662e693aad5f5ec31620b11c1a9.json ├── query-3b3217f05ec62c3affb9d7e183ebe7a0c6ac743c0d8cc37961e4c2071f7925b6.json ├── query-3db2f4454d39b3f74800f3365cb706782494aae97669a83e07a84c7e7cb109de.json ├── query-543731a12e372132dea28e46a33e497d95ac37215411454d69c17ae2fdbb9c40.json ├── query-55028278133f6281dcc44c3bfee83503263a8e4a66122219fa193c46333419d6.json ├── query-5a58be59c5bc784eddb02472c0e0b84c27423b93f8adb038845a2a7ba1a7c70b.json ├── query-61b6ec20b59722891e39e3c9926e6caa9e6a14cc86de4100eaa0f74731a04918.json ├── query-847361b8b35d6daf7dcb40c7e313af26d196d794af755cadeac4e2c608e9b198.json ├── query-9b75008f29ec1a77219a34f2f5c2a74d28cb6d68688d49d5a85ff076177d4e4f.json ├── query-9bb4ddf808e9879ddd6cecebc3cd7517fc40081ae0b95a3b232aad26232c873e.json ├── query-b1ca112d29ed6b3158b07a6226c8e5ec000cd1f00b91691ecb69fda985ee2886.json ├── query-bb63248a41a6b0792290492869b55a5ebe54b5564380b462dac5e779fb61144a.json ├── query-c7a058a5fd638c6deb933f7b6d143119e5d15599be07c581f5333ab872202dcb.json ├── query-d42108e4bf27ca3d1d18c8a94a50e9bf87d5132028d0e03a131bfc8538272d9b.json ├── query-f6edc880836d9f6ee19dc817194c9de68b3c817309b181a0bfb708871726d8e4.json └── query-fd7b310fbf4fc594d28a2980895ded5c6e3ac13543b73665d520e7b3a396a21d.json ├── .vscode └── settings.json ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── Makefile.toml ├── README.md ├── chuckle-gateway ├── Cargo.toml └── src │ ├── events │ ├── interaction_create.rs │ ├── mod.rs │ └── thread_create.rs │ └── lib.rs ├── chuckle-github ├── Cargo.toml ├── data │ └── pull_request_review_comment.json └── src │ ├── lib.rs │ └── pull_request_review_comment.rs ├── chuckle-http ├── Cargo.toml └── src │ ├── lib.rs │ ├── routes │ ├── mod.rs │ ├── status.rs │ └── webhooks.rs │ └── util │ ├── error.rs │ └── mod.rs ├── chuckle-interactions ├── Cargo.toml ├── commands.lock.json ├── deploy-commands.sh └── src │ ├── commands │ ├── breakout_rooms.rs │ ├── config │ │ ├── breakout_category.rs │ │ ├── default_org.rs │ │ ├── default_repo.rs │ │ ├── forum_log.rs │ │ └── mod.rs │ ├── hexil.rs │ ├── link_github.rs │ ├── mod.rs │ ├── ping.rs │ ├── pr_comments.rs │ └── threads.rs │ ├── context_menu │ ├── circle_back.rs │ └── mod.rs │ ├── lib.rs │ └── main.rs ├── chuckle-jobs ├── Cargo.toml └── src │ ├── circle_back.rs │ ├── lib.rs │ └── sweep_notifications.rs ├── chuckle-util ├── Cargo.toml └── src │ ├── chunkify.rs │ ├── config.rs │ ├── db.rs │ ├── lib.rs │ ├── state.rs │ └── timestamptz.rs ├── chuckle ├── Cargo.toml ├── Dockerfile └── src │ └── main.rs ├── docker-compose.yml ├── migrations ├── 0_init.sql ├── 1_modal.sql ├── 2_pr_review.sql ├── 3_hexil.sql ├── 4_guild_settings.sql ├── 5_breakout_rooms.sql ├── 6_drop_modal.sql └── README.md └── rustfmt.toml /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | docker 3 | target 4 | .dockerignore 5 | .gitignore 6 | build.rs 7 | commands.lock.json 8 | deploy-commands.sh 9 | README.md 10 | y.* 11 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgres://admin:oafishcaveman@localhost:5432/chuckle" 2 | #SQLX_OFFLINE=true 3 | -------------------------------------------------------------------------------- /.envrc.example: -------------------------------------------------------------------------------- 1 | export DATABASE_URL="postgres://admin:oafishcaveman@localhost:5432/chuckle" 2 | export ENV=development 3 | export DISCORD_APPLICATION_ID= 4 | export DISCORD_TOKEN= 5 | export RUST_LOG=debug,hyper=info,tower_http=info,rustls=info 6 | export GITHUB_WEBHOOK_SECRET= 7 | # for getting repo files 8 | export GITHUB_ACCESS_TOKEN= 9 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | **Please describe the changes this PR makes and why it should be merged:** 2 | This PR: 3 | - 4 | 5 | **Status and versioning classification:** 6 | 7 | 12 | -------------------------------------------------------------------------------- /.github/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 40 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "cargo": { 4 | "packageRules": [ 5 | { 6 | "automerge": true, 7 | "matchCurrentVersion": "/^0\\./", 8 | "matchUpdateTypes": [ 9 | "patch" 10 | ], 11 | "platformAutomerge": true 12 | }, 13 | { 14 | "automerge": true, 15 | "matchCurrentVersion": ">=1.0.0", 16 | "matchUpdateTypes": [ 17 | "minor", 18 | "patch" 19 | ], 20 | "platformAutomerge": true 21 | } 22 | ], 23 | "rangeStrategy": "bump" 24 | }, 25 | "cloneSubmodules": true, 26 | "extends": [ 27 | "config:base", 28 | "group:allNonMajor", 29 | ":dependencyDashboard" 30 | ], 31 | "labels": [ 32 | "dependencies" 33 | ], 34 | "schedule": [ 35 | "after 6pm" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | push: 7 | paths: 8 | - "**/Cargo.toml" 9 | - "**/Cargo.lock" 10 | 11 | jobs: 12 | audit: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout repository 16 | uses: actions/checkout@v4 17 | with: 18 | ref: ${{ github.event.pull_request.head.ref }} 19 | fetch-depth: 0 20 | 21 | - name: Checkout Repository 22 | uses: actions/checkout@v4 23 | 24 | - name: Register Problem Matchers 25 | uses: r7kamura/rust-problem-matchers@v1 26 | 27 | - run: rustup toolchain install stable --profile minimal 28 | 29 | - name: Cache Cargo 30 | uses: Swatinem/rust-cache@v2 31 | 32 | - name: Setup Rust 33 | uses: dtolnay/rust-toolchain@stable 34 | 35 | - name: Install Cargo Make 36 | uses: davidB/rust-cargo-make@v1 37 | 38 | - name: Run security audit 39 | run: cargo make audit 40 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: Publish Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | slug: ${{ secrets.DOCKER_SLUG }} 10 | dockerfile: ./chuckle/Dockerfile 11 | 12 | jobs: 13 | publish: 14 | runs-on: chortle 15 | steps: 16 | - name: Checkout Repository 17 | uses: actions/checkout@v4 18 | 19 | - name: Generate Image Tag 20 | id: generate_tag 21 | uses: trufflehq/truffle-packages/actions/image_tag@main 22 | 23 | - name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | 26 | - name: Cache Docker layers 27 | uses: actions/cache@v3 28 | with: 29 | path: /tmp/.buildx-cache 30 | key: ${{ runner.os }}-buildx-${{ github.sha }} 31 | restore-keys: | 32 | ${{ runner.os }}-buildx- 33 | 34 | - name: Login to Google Container Registry 35 | uses: docker/login-action@v3 36 | with: 37 | registry: gcr.io 38 | username: _json_key 39 | password: ${{ secrets.GCR_JSON_KEY }} 40 | 41 | - name: Docker meta 42 | id: meta 43 | uses: docker/metadata-action@v5 44 | with: 45 | images: ${{ env.slug }} 46 | tags: | 47 | type=raw,value=latest 48 | type=raw,value=${{ steps.generate_tag.outputs.tag }} 49 | 50 | - name: Build 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | file: ${{ env.dockerfile }} 55 | push: true 56 | tags: ${{ steps.meta.outputs.tags }} 57 | cache-from: type=local,src=/tmp/.buildx-cache 58 | cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max 59 | 60 | # Temp fix 61 | # https://github.com/docker/build-push-action/issues/252 62 | # https://github.com/moby/buildkit/issues/1896 63 | - name: Move cache 64 | run: | 65 | rm -rf /tmp/.buildx-cache 66 | mv /tmp/.buildx-cache-new /tmp/.buildx-cache 67 | -------------------------------------------------------------------------------- /.github/workflows/commands.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Updated Global Commands 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths: 8 | - 'chuckle-interactions/commands.lock.json' 9 | - '.github/workflows/commands.yml' 10 | workflow_dispatch: 11 | 12 | jobs: 13 | deploy: 14 | runs-on: chortle 15 | 16 | steps: 17 | - name: Checkout Commands Lockfile 18 | uses: actions/checkout@v4 19 | with: 20 | sparse-checkout: | 21 | chuckle-interactions/commands.lock.json 22 | sparse-checkout-cone-mode: false 23 | 24 | - name: PUT Global Commands 25 | run: | 26 | curl -X PUT https://discord.com/api/v10/applications/${{ secrets.DISCORD_APPLICATION_ID }}/commands \ 27 | -H "Authorization: Bot ${{ secrets.DISCORD_TOKEN }}" \ 28 | -H "content-type: application/json" \ 29 | -d @./chuckle-interactions/commands.lock.json | jq 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | env: 4 | SQLX_OFFLINE: true 5 | CARGO_TERM_COLOR: always 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | paths-ignore: 12 | - "**.md" 13 | pull_request: 14 | paths-ignore: 15 | - "**.md" 16 | 17 | concurrency: 18 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 19 | cancel-in-progress: true 20 | 21 | jobs: 22 | check: 23 | name: Test Suite 24 | runs-on: chortle 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@v4 28 | 29 | - name: Setup Problem Matchers 30 | uses: r7kamura/rust-problem-matchers@v1 31 | 32 | - run: rustup toolchain install stable --profile minimal 33 | 34 | - name: Rust Cache 35 | uses: Swatinem/rust-cache@v2 36 | 37 | - name: Setup Rust 38 | uses: dtolnay/rust-toolchain@stable 39 | 40 | - name: Install Cargo Make 41 | uses: davidB/rust-cargo-make@v1 42 | 43 | - name: Run Formatter 44 | run: cargo make format-ci 45 | 46 | - name: Run Clippy 47 | run: cargo make lint-ci 48 | 49 | - name: Build 50 | run: cargo check --all-features 51 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .env* 3 | !.env 4 | !.envrc.example 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: f8d8c45220230434bd7440d85a7f64c67bcdb952 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-toml 8 | - id: check-yaml 9 | - id: check-added-large-files 10 | 11 | - repo: local 12 | hooks: 13 | - id: format 14 | name: format 15 | description: Format files with cargo make fmt. 16 | entry: cargo make format 17 | language: system 18 | types: [rust] 19 | pass_filenames: false 20 | - id: lint 21 | name: lint 22 | description: Lint files with cargo make lint. 23 | entry: cargo make lint 24 | language: system 25 | types: [rust] 26 | pass_filenames: false 27 | - id: check 28 | name: check 29 | description: Check files with Cargo Check 30 | entry: cargo check 31 | language: system 32 | types: [rust] 33 | pass_filenames: false 34 | -------------------------------------------------------------------------------- /.sqlx/query-07ca64b10025a77a597f7d003c7bf4fdd0b1a7a9b23f9246c6dab120a91f0498.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set default_repository_owner = $1 where guild_id = $2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "07ca64b10025a77a597f7d003c7bf4fdd0b1a7a9b23f9246c6dab120a91f0498" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-0b5aa446aa706d7d3b4f4e485d589f2c116d529b46e74f3440201d08b6c56bf4.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "select * from pr_review_output where pr_number = $1 and repo_owner = $2 and repo = $3;", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "pr_number", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "repo_owner", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "repo", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "thread_id", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "created_at", 34 | "type_info": "Timestamptz" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Int4", 40 | "Text", 41 | "Text" 42 | ] 43 | }, 44 | "nullable": [ 45 | false, 46 | false, 47 | false, 48 | false, 49 | false, 50 | false 51 | ] 52 | }, 53 | "hash": "0b5aa446aa706d7d3b4f4e485d589f2c116d529b46e74f3440201d08b6c56bf4" 54 | } 55 | -------------------------------------------------------------------------------- /.sqlx/query-10fd2084c314c5a357f7408746702d90c908a44c3148b03de91bcf5eaee9712e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update notifications set completed = true where id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Uuid" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "10fd2084c314c5a357f7408746702d90c908a44c3148b03de91bcf5eaee9712e" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-14c35efbbe2e0c6c2edcec300f4f9f58257c5bf84a28f1ac2156539edc3ba6e0.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update \"user\" set github_id = $1 where id = $2 returning id", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Int4", 15 | "Uuid" 16 | ] 17 | }, 18 | "nullable": [ 19 | false 20 | ] 21 | }, 22 | "hash": "14c35efbbe2e0c6c2edcec300f4f9f58257c5bf84a28f1ac2156539edc3ba6e0" 23 | } 24 | -------------------------------------------------------------------------------- /.sqlx/query-1a7ad5f985960145009f82ab41eda810cf2f14b510e5196aaf6684d945daf017.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "insert into \"hexil\" (guild_id, user_id, role_id) values ($1, $2, $3)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text", 10 | "Text" 11 | ] 12 | }, 13 | "nullable": [] 14 | }, 15 | "hash": "1a7ad5f985960145009f82ab41eda810cf2f14b510e5196aaf6684d945daf017" 16 | } 17 | -------------------------------------------------------------------------------- /.sqlx/query-2971ad0c94858b6c33d6ba863ea2e877fd574267d806ed1f7593c5cbb14ba1a2.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "insert into notifications (author_id, user_id, guild_id, channel_id, message_id, notify_at) values ($1, $2, $3, $4, $5, $6) returning id", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Int8", 15 | "Int8", 16 | "Int8", 17 | "Int8", 18 | "Int8", 19 | "Timestamptz" 20 | ] 21 | }, 22 | "nullable": [ 23 | false 24 | ] 25 | }, 26 | "hash": "2971ad0c94858b6c33d6ba863ea2e877fd574267d806ed1f7593c5cbb14ba1a2" 27 | } 28 | -------------------------------------------------------------------------------- /.sqlx/query-2a9879a85acc57d9a732657e57494dfb60c75f544f90e0c328e8c9bc34901f8e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set breakout_rooms_category_id = $1 where guild_id = $2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "2a9879a85acc57d9a732657e57494dfb60c75f544f90e0c328e8c9bc34901f8e" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-34c16baa9bad362981992f30483bf07b6af29662e693aad5f5ec31620b11c1a9.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set default_repository = null where guild_id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "34c16baa9bad362981992f30483bf07b6af29662e693aad5f5ec31620b11c1a9" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-3b3217f05ec62c3affb9d7e183ebe7a0c6ac743c0d8cc37961e4c2071f7925b6.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "insert into \"user\" (discord_id, github_id) values ($1, $2)", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Int4" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "3b3217f05ec62c3affb9d7e183ebe7a0c6ac743c0d8cc37961e4c2071f7925b6" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-3db2f4454d39b3f74800f3365cb706782494aae97669a83e07a84c7e7cb109de.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set default_repository_owner = null where guild_id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "3db2f4454d39b3f74800f3365cb706782494aae97669a83e07a84c7e7cb109de" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-543731a12e372132dea28e46a33e497d95ac37215411454d69c17ae2fdbb9c40.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "insert into guild_settings (guild_id) values ($1) returning *", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "guild_id", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "forum_log_channel_id", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "default_repository", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "default_repository_owner", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "created_at", 34 | "type_info": "Timestamptz" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "breakout_rooms_category_id", 39 | "type_info": "Text" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Text" 45 | ] 46 | }, 47 | "nullable": [ 48 | false, 49 | false, 50 | true, 51 | true, 52 | true, 53 | false, 54 | true 55 | ] 56 | }, 57 | "hash": "543731a12e372132dea28e46a33e497d95ac37215411454d69c17ae2fdbb9c40" 58 | } 59 | -------------------------------------------------------------------------------- /.sqlx/query-55028278133f6281dcc44c3bfee83503263a8e4a66122219fa193c46333419d6.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set forum_log_channel_id = $1 where guild_id = $2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "55028278133f6281dcc44c3bfee83503263a8e4a66122219fa193c46333419d6" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-5a58be59c5bc784eddb02472c0e0b84c27423b93f8adb038845a2a7ba1a7c70b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set breakout_rooms_category_id = null where guild_id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "5a58be59c5bc784eddb02472c0e0b84c27423b93f8adb038845a2a7ba1a7c70b" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-61b6ec20b59722891e39e3c9926e6caa9e6a14cc86de4100eaa0f74731a04918.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "select * from \"user\" where github_id = $1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "discord_id", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "github_id", 19 | "type_info": "Int4" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "created_at", 24 | "type_info": "Timestamptz" 25 | } 26 | ], 27 | "parameters": { 28 | "Left": [ 29 | "Int4" 30 | ] 31 | }, 32 | "nullable": [ 33 | false, 34 | true, 35 | true, 36 | false 37 | ] 38 | }, 39 | "hash": "61b6ec20b59722891e39e3c9926e6caa9e6a14cc86de4100eaa0f74731a04918" 40 | } 41 | -------------------------------------------------------------------------------- /.sqlx/query-847361b8b35d6daf7dcb40c7e313af26d196d794af755cadeac4e2c608e9b198.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT * FROM pr_review_output WHERE pr_number = $1 AND repo_owner = $2 AND repo = $3", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "pr_number", 14 | "type_info": "Int4" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "repo_owner", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "repo", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "thread_id", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "created_at", 34 | "type_info": "Timestamptz" 35 | } 36 | ], 37 | "parameters": { 38 | "Left": [ 39 | "Int4", 40 | "Text", 41 | "Text" 42 | ] 43 | }, 44 | "nullable": [ 45 | false, 46 | false, 47 | false, 48 | false, 49 | false, 50 | false 51 | ] 52 | }, 53 | "hash": "847361b8b35d6daf7dcb40c7e313af26d196d794af755cadeac4e2c608e9b198" 54 | } 55 | -------------------------------------------------------------------------------- /.sqlx/query-9b75008f29ec1a77219a34f2f5c2a74d28cb6d68688d49d5a85ff076177d4e4f.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "SELECT * FROM \"user\" WHERE discord_id = $1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "discord_id", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "github_id", 19 | "type_info": "Int4" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "created_at", 24 | "type_info": "Timestamptz" 25 | } 26 | ], 27 | "parameters": { 28 | "Left": [ 29 | "Text" 30 | ] 31 | }, 32 | "nullable": [ 33 | false, 34 | true, 35 | true, 36 | false 37 | ] 38 | }, 39 | "hash": "9b75008f29ec1a77219a34f2f5c2a74d28cb6d68688d49d5a85ff076177d4e4f" 40 | } 41 | -------------------------------------------------------------------------------- /.sqlx/query-9bb4ddf808e9879ddd6cecebc3cd7517fc40081ae0b95a3b232aad26232c873e.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "insert into pr_review_output (pr_number, repo_owner, repo, thread_id) values ($1, $2, $3, $4) returning id", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Int4", 15 | "Text", 16 | "Text", 17 | "Text" 18 | ] 19 | }, 20 | "nullable": [ 21 | false 22 | ] 23 | }, 24 | "hash": "9bb4ddf808e9879ddd6cecebc3cd7517fc40081ae0b95a3b232aad26232c873e" 25 | } 26 | -------------------------------------------------------------------------------- /.sqlx/query-b1ca112d29ed6b3158b07a6226c8e5ec000cd1f00b91691ecb69fda985ee2886.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "select id, user_id, author_id, guild_id, channel_id, message_id, notify_at from notifications where notify_at < now() and completed = false", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "user_id", 14 | "type_info": "Int8" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "author_id", 19 | "type_info": "Int8" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "guild_id", 24 | "type_info": "Int8" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "channel_id", 29 | "type_info": "Int8" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "message_id", 34 | "type_info": "Int8" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "notify_at", 39 | "type_info": "Timestamptz" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [] 44 | }, 45 | "nullable": [ 46 | false, 47 | false, 48 | false, 49 | false, 50 | false, 51 | false, 52 | false 53 | ] 54 | }, 55 | "hash": "b1ca112d29ed6b3158b07a6226c8e5ec000cd1f00b91691ecb69fda985ee2886" 56 | } 57 | -------------------------------------------------------------------------------- /.sqlx/query-bb63248a41a6b0792290492869b55a5ebe54b5564380b462dac5e779fb61144a.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set forum_log_channel_id = null where guild_id = $1", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text" 9 | ] 10 | }, 11 | "nullable": [] 12 | }, 13 | "hash": "bb63248a41a6b0792290492869b55a5ebe54b5564380b462dac5e779fb61144a" 14 | } 15 | -------------------------------------------------------------------------------- /.sqlx/query-c7a058a5fd638c6deb933f7b6d143119e5d15599be07c581f5333ab872202dcb.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update pr_review_output set thread_id = $1 where id = $2 returning id", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | } 11 | ], 12 | "parameters": { 13 | "Left": [ 14 | "Text", 15 | "Uuid" 16 | ] 17 | }, 18 | "nullable": [ 19 | false 20 | ] 21 | }, 22 | "hash": "c7a058a5fd638c6deb933f7b6d143119e5d15599be07c581f5333ab872202dcb" 23 | } 24 | -------------------------------------------------------------------------------- /.sqlx/query-d42108e4bf27ca3d1d18c8a94a50e9bf87d5132028d0e03a131bfc8538272d9b.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "select * from guild_settings where guild_id = $1", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "guild_id", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "forum_log_channel_id", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "default_repository", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "default_repository_owner", 29 | "type_info": "Text" 30 | }, 31 | { 32 | "ordinal": 5, 33 | "name": "created_at", 34 | "type_info": "Timestamptz" 35 | }, 36 | { 37 | "ordinal": 6, 38 | "name": "breakout_rooms_category_id", 39 | "type_info": "Text" 40 | } 41 | ], 42 | "parameters": { 43 | "Left": [ 44 | "Text" 45 | ] 46 | }, 47 | "nullable": [ 48 | false, 49 | false, 50 | true, 51 | true, 52 | true, 53 | false, 54 | true 55 | ] 56 | }, 57 | "hash": "d42108e4bf27ca3d1d18c8a94a50e9bf87d5132028d0e03a131bfc8538272d9b" 58 | } 59 | -------------------------------------------------------------------------------- /.sqlx/query-f6edc880836d9f6ee19dc817194c9de68b3c817309b181a0bfb708871726d8e4.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "update guild_settings set default_repository = $1 where guild_id = $2", 4 | "describe": { 5 | "columns": [], 6 | "parameters": { 7 | "Left": [ 8 | "Text", 9 | "Text" 10 | ] 11 | }, 12 | "nullable": [] 13 | }, 14 | "hash": "f6edc880836d9f6ee19dc817194c9de68b3c817309b181a0bfb708871726d8e4" 15 | } 16 | -------------------------------------------------------------------------------- /.sqlx/query-fd7b310fbf4fc594d28a2980895ded5c6e3ac13543b73665d520e7b3a396a21d.json: -------------------------------------------------------------------------------- 1 | { 2 | "db_name": "PostgreSQL", 3 | "query": "select * from hexil where guild_id = $1 and user_id = $2", 4 | "describe": { 5 | "columns": [ 6 | { 7 | "ordinal": 0, 8 | "name": "id", 9 | "type_info": "Uuid" 10 | }, 11 | { 12 | "ordinal": 1, 13 | "name": "guild_id", 14 | "type_info": "Text" 15 | }, 16 | { 17 | "ordinal": 2, 18 | "name": "user_id", 19 | "type_info": "Text" 20 | }, 21 | { 22 | "ordinal": 3, 23 | "name": "role_id", 24 | "type_info": "Text" 25 | }, 26 | { 27 | "ordinal": 4, 28 | "name": "created_at", 29 | "type_info": "Timestamptz" 30 | } 31 | ], 32 | "parameters": { 33 | "Left": [ 34 | "Text", 35 | "Text" 36 | ] 37 | }, 38 | "nullable": [ 39 | false, 40 | false, 41 | false, 42 | false, 43 | false 44 | ] 45 | }, 46 | "hash": "fd7b310fbf4fc594d28a2980895ded5c6e3ac13543b73665d520e7b3a396a21d" 47 | } 48 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.inlayHints.enabled": "off" 3 | } 4 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "chuckle", 4 | "chuckle-gateway", 5 | "chuckle-github", 6 | "chuckle-http", 7 | "chuckle-interactions", 8 | "chuckle-jobs", 9 | "chuckle-util" 10 | ] 11 | resolver = "2" 12 | 13 | [workspace.package] 14 | version = "0.1.0" 15 | license = "MIT OR Apache-2.0" 16 | edition = "2021" 17 | repository = "https://github.com/trufflehq/chuckle" 18 | keywords = ["discord", "trufflehq", "truffle", "youtube", "twitch"] 19 | authors = [ 20 | "Carter Himmel " 21 | ] 22 | homepage = "https://github.com/trufflehq/chuckle#readme" 23 | 24 | [workspace.dependencies] 25 | chuckle-gateway = { path = "./chuckle-gateway" } 26 | chuckle-github = { path = "./chuckle-github" } 27 | chuckle-http = { path = "./chuckle-http" } 28 | chuckle-interactions = { path = "./chuckle-interactions", default-features = false } 29 | chuckle-jobs = { path = "./chuckle-jobs" } 30 | chuckle-util = { path = "./chuckle-util" } 31 | 32 | anyhow = "1" 33 | async-trait = "0.1" 34 | axum = { version = "0.6", features = ["macros", "multipart"] } 35 | chrono = "0.4" 36 | hex = "0.4" 37 | once_cell = "1" 38 | redis = { version = "0.23", features = ["tokio-comp", "connection-manager"] } 39 | reqwest = { version = "0.11", features = ["json"] } 40 | serde = { version = "1", features = ["derive"] } 41 | serde_json = { version = "1", features = ["preserve_order"] } 42 | tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } 43 | sqlx = { version = "0.7", features = [ "runtime-tokio-rustls", "postgres", "uuid", "time" ] } 44 | time = "0.3" 45 | tracing = "0.1" 46 | tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter", "json"] } 47 | twilight-cache-inmemory = { version = "0.15", features = ["permission-calculator"] } 48 | twilight-gateway = { version = "0.15", default-features = false, features = ["rustls-webpki-roots"] } 49 | twilight-http = { version = "0.15", default-features = false, features = ["rustls-webpki-roots"] } 50 | twilight-model = "0.15" 51 | twilight-util = { version = "0.15", features = ["builder"] } 52 | uuid = { version = "1", features = ["serde", "v4"] } 53 | vesper = { version = "0.11", features = ["bulk"] } 54 | 55 | [profile.dev.package.sqlx-macros] 56 | opt-level = 3 57 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Spore, Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Spore, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile.toml: -------------------------------------------------------------------------------- 1 | [env] 2 | CARGO_MAKE_WORKSPACE_EMULATION = true 3 | 4 | [config] 5 | main_project_member = "chuckle" 6 | default_to_workspace = false 7 | 8 | [tasks.setup] 9 | script = ''' 10 | echo # installing git hooks 11 | pre-commit --version || pip install pre-commit 12 | pre-commit install || echo "failed to install git hooks!" 1>&2 13 | 14 | echo # things required by sqlx 15 | cargo install sqlx-cli@0.7.2 --no-default-features --features native-tls,postgres 16 | 17 | echo # things required by `cargo make sort-deps` 18 | cargo install cargo-sort 19 | ''' 20 | 21 | [tasks.lint] 22 | command = "cargo" 23 | args = [ 24 | "clippy", 25 | "--tests", 26 | "--examples", 27 | "--all-targets", 28 | "--all-features", 29 | "--workspace", 30 | ] 31 | env = { SQLX_OFFLINE = "true" } 32 | 33 | [tasks.lint-ci] 34 | command = "cargo" 35 | args = [ 36 | "clippy", 37 | "--tests", 38 | "--examples", 39 | "--all-targets", 40 | "--all-features", 41 | "--workspace", 42 | "--", 43 | "-D", 44 | "warnings", 45 | ] 46 | 47 | [tasks.sort-deps] 48 | command = "cargo" 49 | args = [ 50 | "sort", 51 | "--workspace", 52 | "--grouped" 53 | ] 54 | 55 | [tasks.format] 56 | install_crate = "rustfmt" 57 | command = "cargo" 58 | args = ["fmt", "--all"] 59 | 60 | [tasks.fmt] 61 | alias = "format" 62 | 63 | [tasks.format-ci] 64 | install_crate = "rustfmt" 65 | command = "cargo" 66 | args = ["fmt", "--all", "--", "--check"] 67 | 68 | [tasks.audit] 69 | command = "cargo" 70 | args = ["audit"] 71 | 72 | [tasks.timings] 73 | script = ''' 74 | cargo clean 75 | cargo build --release --quiet --timings 76 | xdg-open /target/cargo-timings/cargo-timing.html 77 | ''' 78 | 79 | [tasks.dev] 80 | env = { RUST_LOG = "info" } 81 | command = "cargo" 82 | args = ["run", "--bin", "chuckle"] 83 | watch = { watch = ["chuckle", "chuckle-gateway", "chuckle-interactions", "chuckle-util"] } 84 | 85 | [tasks.commands-lockfile] 86 | command = "cargo" 87 | args = ["run", "--bin", "chuckle-interactions"] 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

4 | chuckle logo 5 |

6 |
7 | Discord Server 8 | Test status 9 | Command deployment status 10 |
11 | 12 | ## About 13 | Chuckle is our in-house Discord bot for our internal company server. 14 | We weren't a huge fan of Slack and, most of our target demographic uses Discord. 15 | 16 | A few of our favorite (and only :p) features include: 17 | - Circle Back, create reminders to revisit a specific message 18 | - PR Comments, stream PR reviews and updates to a configured thread 19 | - `/hexil`, allow each member to set a custom role/name color 20 | 21 | # Development 22 | 23 | ## Requirements 24 | These are some broad, general requirements for running Chuckle. 25 | - [Rust](https://rust-lang.org/tools/install) 26 | - [Docker](https://docs.docker.com/engine/install/) 27 | 28 | ## Setup 29 | Before you can actually setup... we have some setup to do! 30 | 31 | 1. Install [`cargo-make`](https://github.com/sagiegurari/cargo-make#installation) (`cargo install --force cargo-make`) 32 | 33 | Cargo doesn't have a native "scripts" feature like Yarn or NPM. Thus, we use `cargo-make` and [`Makefile.toml`](./Makefile.toml). 34 | 35 | ~~2. Install [`pre-commit`](https://pre-commit.com/#installation) (`pip install pre-commit`)~~ 36 | 37 | ~~We use this for running git hooks.~~ This is handled by the next step. 38 | 39 | 2. Run `cargo make setup` 40 | 41 | This installs necessary components for other scripts and development fun. 42 | 43 | ### Environment 44 | If it weren't for `sqlx` and it's inability to play nice with `direnv`, we wouldn't also need an `.env` file containing just the `DATABASE_URL`. 45 | 46 | 1. Install [direnv](https://direnv.net/#basic-installation). 47 | 48 | It automatically loads our `.direnv` file. 49 | 50 | 2. Copy `.envrc.example` to `.envrc` and fill with your environment variables. 51 | 52 | 3. Ensure `.env` houses your `DATABASE_URL` address. 53 | 54 | ### Database 55 | We utilize `sqlx`'s compile-time checked queries, which requires a database connection during development. 56 | Additionally, we use `sqlx`'s migrations tool, which is just a treat! 57 | 58 | 1. Start the database with `docker compose up -d`. 59 | 60 | 2. Run `sqlx migrate run` 61 | 62 | This applies our database migrations. 63 | 64 | ## Running 65 | Now, running the bot should be as easy as: 66 | 67 | 1. `cargo make dev` 68 | 69 | ## Contributing 70 | When making changes to Chuckle, there are a few things you must take into consideration. 71 | 72 | If you make any query changes, you must run `cargo sqlx prepare` to create an entry in [`.sqlx`](./.sqlx) to support `SQLX_OFFLINE`. 73 | If you make any command/interaction data changes, you must run `cargo make commands-lockfile` to remake the [commands.lock.json](./chuckle-interactions/commands.lock.json) file. 74 | 75 | Regardless, of what scope, you must always ensure Clippy, Rustfmt and cargo-check are satisified, as done with pre-commit hooks. 76 | 77 | 78 | # Production 79 | We currently host Chuckle on our Google Kubernetes Engine cluster. 80 | ```mermaid 81 | flowchart TD 82 | commands[" 83 | Update Discord 84 | Commands 85 | "] 86 | test[" 87 | Lint, Format 88 | and Build 89 | "] 90 | commit[Push to main] --> test 91 | commit ---> deploy[Deploy to GCR] 92 | commit -- " 93 | commands.lock.json 94 | updated? 95 | " ---> commands 96 | commit -- " 97 | migrations 98 | updated? 99 | " --> cloudsql[Connect to Cloud SQL] 100 | --> migrations[Apply Migrations] 101 | ``` 102 | 103 | ## Building Chuckle 104 | 105 | todo, see our [.github/workflows](./.github/workflows) 106 | -------------------------------------------------------------------------------- /chuckle-gateway/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chuckle-gateway" 3 | description = "The Discord gateway connection for Chuckle." 4 | version = { workspace = true } 5 | license = { workspace = true } 6 | edition = { workspace = true } 7 | authors = { workspace = true } 8 | repository = { workspace = true } 9 | homepage = { workspace = true } 10 | 11 | [dependencies] 12 | chuckle-interactions = { workspace = true } 13 | chuckle-util = { workspace = true } 14 | 15 | anyhow = { workspace = true } 16 | once_cell = { workspace = true } 17 | tokio = { workspace = true, features = ["rt"] } 18 | tracing = { workspace = true } 19 | twilight-gateway = { workspace = true } 20 | twilight-model = { workspace = true } 21 | -------------------------------------------------------------------------------- /chuckle-gateway/src/events/interaction_create.rs: -------------------------------------------------------------------------------- 1 | use chuckle_interactions::ChuckleFramework; 2 | use twilight_model::{ 3 | application::interaction::{InteractionData, InteractionType}, 4 | gateway::payload::incoming::InteractionCreate, 5 | }; 6 | 7 | pub async fn handle( 8 | framework: ChuckleFramework, 9 | event: Box, 10 | ) -> anyhow::Result<()> { 11 | if !event.is_guild() { 12 | return Ok(()); // dms 13 | } 14 | let interaction = event.0; 15 | tracing::info!("Received an interaction {:#?}", interaction.kind); 16 | 17 | match interaction.kind { 18 | InteractionType::Ping => unimplemented!("should be unnecessary via gateway"), 19 | InteractionType::ApplicationCommand => match interaction.clone().data { 20 | Some(InteractionData::ApplicationCommand(data)) => { 21 | tracing::info!("received application command: {:?}", data.kind); 22 | framework.process(interaction).await; 23 | 24 | Ok(()) 25 | } 26 | _ => Ok(()), 27 | }, 28 | InteractionType::ModalSubmit => { 29 | framework.process(interaction).await; 30 | 31 | Ok(()) 32 | } 33 | InteractionType::MessageComponent => unimplemented!(), 34 | InteractionType::ApplicationCommandAutocomplete => unimplemented!(""), 35 | _ => unimplemented!(), 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /chuckle-gateway/src/events/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod interaction_create; 2 | pub mod thread_create; 3 | -------------------------------------------------------------------------------- /chuckle-gateway/src/events/thread_create.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use std::str::FromStr; 3 | use twilight_model::{ 4 | channel::ChannelType, 5 | gateway::payload::incoming::ThreadCreate, 6 | id::{marker::ChannelMarker, Id}, 7 | }; 8 | 9 | pub async fn handle(state: ChuckleState, event: Box) -> anyhow::Result<()> { 10 | if event.parent_id.is_none() && event.guild_id.is_none() { 11 | return Ok(()); // not a thread 12 | } 13 | 14 | if !event.newly_created.unwrap_or(false) { 15 | return Ok(()); // not a new thread 16 | } 17 | 18 | let parent = state.http_client.channel(event.parent_id.unwrap()).await; 19 | let parent = match parent { 20 | Ok(parent) => parent.model().await?, 21 | Err(_) => return Ok(()), // parent channel not found 22 | }; 23 | 24 | if parent.kind != ChannelType::GuildForum { 25 | return Ok(()); // non-forum 26 | } 27 | 28 | let settings = get_settings(&state, event.guild_id.unwrap()).await?; 29 | if settings.forum_log_channel_id.is_none() { 30 | return Ok(()); // no forum log channel set 31 | } 32 | let log_id: Id = 33 | Id::::from_str(&settings.forum_log_channel_id.unwrap()).unwrap(); 34 | 35 | let content = format!( 36 | "<@{}> created <#{}> in <#{}>", 37 | event.owner_id.unwrap(), 38 | event.id, 39 | parent.id 40 | ); 41 | let _ = state 42 | .http_client 43 | .create_message(log_id) 44 | .content(&content)? 45 | .await; 46 | 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /chuckle-gateway/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod events; 2 | 3 | use crate::events::{interaction_create, thread_create}; 4 | use anyhow::Result; 5 | use chuckle_interactions::ChuckleFramework; 6 | use chuckle_util::{ChuckleState, CONFIG}; 7 | use twilight_gateway::{Config, Event, EventTypeFlags, Intents, Shard, ShardId}; 8 | 9 | const BOT_EVENTS: EventTypeFlags = EventTypeFlags::from_bits_truncate( 10 | EventTypeFlags::READY.bits() 11 | | EventTypeFlags::GUILD_CREATE.bits() 12 | | EventTypeFlags::GUILD_DELETE.bits() 13 | | EventTypeFlags::THREAD_CREATE.bits() 14 | | EventTypeFlags::CHANNEL_CREATE.bits() 15 | | EventTypeFlags::CHANNEL_UPDATE.bits() 16 | | EventTypeFlags::CHANNEL_DELETE.bits() 17 | | EventTypeFlags::INTERACTION_CREATE.bits() 18 | | EventTypeFlags::GUILD_VOICE_STATES.bits() 19 | | EventTypeFlags::GUILD_MEMBERS.bits(), 20 | ); 21 | 22 | pub async fn create_gateway(state: ChuckleState, framework: ChuckleFramework) -> Result<()> { 23 | let config = Config::builder( 24 | CONFIG.discord_token.clone(), 25 | Intents::GUILDS | Intents::GUILD_VOICE_STATES | Intents::GUILD_MEMBERS, 26 | ) 27 | .event_types(BOT_EVENTS) 28 | .build(); 29 | let mut shard = Shard::with_config(ShardId::ONE, config); 30 | 31 | loop { 32 | let event = match shard.next_event().await { 33 | Ok(event) => event, 34 | Err(source) => { 35 | tracing::warn!(?source, "error recieving event"); 36 | continue; 37 | } 38 | }; 39 | 40 | let state = state.clone(); 41 | let framework = framework.clone(); 42 | tokio::spawn(handle_event(state, framework, event)); 43 | } 44 | } 45 | 46 | #[allow(clippy::unit_arg)] 47 | pub async fn handle_event( 48 | state: ChuckleState, 49 | framework: ChuckleFramework, 50 | event: Event, 51 | ) -> Result<()> { 52 | let shard_id = 1; 53 | state.cache.update(&event); 54 | 55 | match event { 56 | Event::GatewayHeartbeat(heart) => { 57 | Ok(tracing::debug!("Shard {shard_id} heartbeat: {heart}")) 58 | } 59 | Event::ThreadCreate(event) => thread_create::handle(state, event).await, 60 | Event::InteractionCreate(event) => interaction_create::handle(framework, event).await, 61 | Event::Ready(_) => Ok(tracing::info!("Shard {shard_id} connected; client ready!")), 62 | Event::GatewayReconnect => Ok(tracing::info! { 63 | target: "gateway_reconnect", 64 | "shard {shard_id} gateway reconnecting" 65 | }), 66 | Event::GuildCreate(guild) => Ok(tracing::info!( 67 | "guild_create: received {} ({})", 68 | guild.name, 69 | guild.id, 70 | )), 71 | Event::GuildDelete(guild) => Ok(tracing::info!( 72 | "[event::guilddelete] shard {shard_id} guild delete {}", 73 | guild.id 74 | )), 75 | _ => Ok(tracing::debug!( 76 | "shard {shard_id} emitted {:?}", 77 | event.kind() 78 | )), 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /chuckle-github/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chuckle-github" 3 | description = "GitHub models and logic for PR review comments &e" 4 | version = { workspace = true } 5 | license = { workspace = true } 6 | edition = { workspace = true } 7 | authors = { workspace = true } 8 | repository = { workspace = true } 9 | homepage = { workspace = true } 10 | 11 | [dependencies] 12 | anyhow = { workspace = true } 13 | reqwest = { workspace = true } 14 | serde = { workspace = true } 15 | serde_json = { workspace = true } 16 | 17 | [dev-dependencies] 18 | tokio = { workspace = true } 19 | format_serde_error = "0.3" 20 | -------------------------------------------------------------------------------- /chuckle-github/data/pull_request_review_comment.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "comment": { 4 | "_links": { 5 | "html": { 6 | "href": "https://github.com/trufflehq/chonk/pull/116#discussion_r1251639456" 7 | }, 8 | "pull_request": { 9 | "href": "https://api.github.com/repos/trufflehq/chonk/pulls/116" 10 | }, 11 | "self": { 12 | "href": "https://api.github.com/repos/trufflehq/chonk/pulls/comments/1251639456" 13 | } 14 | }, 15 | "author_association": "CONTRIBUTOR", 16 | "body": "don't forget to always use `> `", 17 | "commit_id": "c71e4dfab43e7ede9919e5adb51a717366c798cc", 18 | "created_at": "2023-07-04T07:52:39Z", 19 | "diff_hunk": "@@ -0,0 +1,78 @@\n+import { css } from '../../../deps/styles.ts';\n+\n+export default css`\n+ .p-global-profile-page {\n+ margin-left: 40px;\n+ max-width: 680px;\n+ margin-top: 32px;\n+ > .c-edit-me-form {\n+ max-width: 440px;\n+ margin: 12px 0px;\n+ margin-bottom: 30px;\n+ h3 {", 20 | "html_url": "https://github.com/trufflehq/chonk/pull/116#discussion_r1251639456", 21 | "id": 1251639456, 22 | "line": 12, 23 | "node_id": "PRRC_kwDOIWNBLM5KmoCg", 24 | "original_commit_id": "c71e4dfab43e7ede9919e5adb51a717366c798cc", 25 | "original_line": 12, 26 | "original_position": 12, 27 | "original_start_line": null, 28 | "path": "sites/app.truffle.vip/src/pages/user-settings/global-profile-page/global-profile-page.scss.ts", 29 | "position": 12, 30 | "pull_request_review_id": 1512289374, 31 | "pull_request_url": "https://api.github.com/repos/trufflehq/chonk/pulls/116", 32 | "reactions": { 33 | "+1": 0, 34 | "-1": 0, 35 | "confused": 0, 36 | "eyes": 0, 37 | "heart": 0, 38 | "hooray": 0, 39 | "laugh": 0, 40 | "rocket": 0, 41 | "total_count": 0, 42 | "url": "https://api.github.com/repos/trufflehq/chonk/pulls/comments/1251639456/reactions" 43 | }, 44 | "side": "RIGHT", 45 | "start_line": null, 46 | "start_side": null, 47 | "subject_type": "line", 48 | "updated_at": "2023-07-04T07:52:40Z", 49 | "url": "https://api.github.com/repos/trufflehq/chonk/pulls/comments/1251639456", 50 | "user": { 51 | "avatar_url": "https://avatars.githubusercontent.com/u/1271767?v=4", 52 | "events_url": "https://api.github.com/users/austinhallock/events{/privacy}", 53 | "followers_url": "https://api.github.com/users/austinhallock/followers", 54 | "following_url": "https://api.github.com/users/austinhallock/following{/other_user}", 55 | "gists_url": "https://api.github.com/users/austinhallock/gists{/gist_id}", 56 | "gravatar_id": "", 57 | "html_url": "https://github.com/austinhallock", 58 | "id": 1271767, 59 | "login": "austinhallock", 60 | "node_id": "MDQ6VXNlcjEyNzE3Njc=", 61 | "organizations_url": "https://api.github.com/users/austinhallock/orgs", 62 | "received_events_url": "https://api.github.com/users/austinhallock/received_events", 63 | "repos_url": "https://api.github.com/users/austinhallock/repos", 64 | "site_admin": false, 65 | "starred_url": "https://api.github.com/users/austinhallock/starred{/owner}{/repo}", 66 | "subscriptions_url": "https://api.github.com/users/austinhallock/subscriptions", 67 | "type": "User", 68 | "url": "https://api.github.com/users/austinhallock" 69 | } 70 | }, 71 | "organization": { 72 | "avatar_url": "https://avatars.githubusercontent.com/u/76624237?v=4", 73 | "description": "Building cool shit for creators.", 74 | "events_url": "https://api.github.com/orgs/trufflehq/events", 75 | "hooks_url": "https://api.github.com/orgs/trufflehq/hooks", 76 | "id": 76624237, 77 | "issues_url": "https://api.github.com/orgs/trufflehq/issues", 78 | "login": "trufflehq", 79 | "members_url": "https://api.github.com/orgs/trufflehq/members{/member}", 80 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjc2NjI0MjM3", 81 | "public_members_url": "https://api.github.com/orgs/trufflehq/public_members{/member}", 82 | "repos_url": "https://api.github.com/orgs/trufflehq/repos", 83 | "url": "https://api.github.com/orgs/trufflehq" 84 | }, 85 | "pull_request": { 86 | "_links": { 87 | "comments": { 88 | "href": "https://api.github.com/repos/trufflehq/chonk/issues/116/comments" 89 | }, 90 | "commits": { 91 | "href": "https://api.github.com/repos/trufflehq/chonk/pulls/116/commits" 92 | }, 93 | "html": { 94 | "href": "https://github.com/trufflehq/chonk/pull/116" 95 | }, 96 | "issue": { 97 | "href": "https://api.github.com/repos/trufflehq/chonk/issues/116" 98 | }, 99 | "review_comment": { 100 | "href": "https://api.github.com/repos/trufflehq/chonk/pulls/comments{/number}" 101 | }, 102 | "review_comments": { 103 | "href": "https://api.github.com/repos/trufflehq/chonk/pulls/116/comments" 104 | }, 105 | "self": { 106 | "href": "https://api.github.com/repos/trufflehq/chonk/pulls/116" 107 | }, 108 | "statuses": { 109 | "href": "https://api.github.com/repos/trufflehq/chonk/statuses/c71e4dfab43e7ede9919e5adb51a717366c798cc" 110 | } 111 | }, 112 | "active_lock_reason": null, 113 | "assignee": null, 114 | "assignees": [], 115 | "author_association": "CONTRIBUTOR", 116 | "auto_merge": null, 117 | "base": { 118 | "label": "trufflehq:main", 119 | "ref": "main", 120 | "repo": { 121 | "allow_auto_merge": false, 122 | "allow_forking": false, 123 | "allow_merge_commit": false, 124 | "allow_rebase_merge": false, 125 | "allow_squash_merge": true, 126 | "allow_update_branch": false, 127 | "archive_url": "https://api.github.com/repos/trufflehq/chonk/{archive_format}{/ref}", 128 | "archived": false, 129 | "assignees_url": "https://api.github.com/repos/trufflehq/chonk/assignees{/user}", 130 | "blobs_url": "https://api.github.com/repos/trufflehq/chonk/git/blobs{/sha}", 131 | "branches_url": "https://api.github.com/repos/trufflehq/chonk/branches{/branch}", 132 | "clone_url": "https://github.com/trufflehq/chonk.git", 133 | "collaborators_url": "https://api.github.com/repos/trufflehq/chonk/collaborators{/collaborator}", 134 | "comments_url": "https://api.github.com/repos/trufflehq/chonk/comments{/number}", 135 | "commits_url": "https://api.github.com/repos/trufflehq/chonk/commits{/sha}", 136 | "compare_url": "https://api.github.com/repos/trufflehq/chonk/compare/{base}...{head}", 137 | "contents_url": "https://api.github.com/repos/trufflehq/chonk/contents/{+path}", 138 | "contributors_url": "https://api.github.com/repos/trufflehq/chonk/contributors", 139 | "created_at": "2022-10-31T21:07:22Z", 140 | "default_branch": "main", 141 | "delete_branch_on_merge": false, 142 | "deployments_url": "https://api.github.com/repos/trufflehq/chonk/deployments", 143 | "description": "all the services! so many codes! so much chonk!", 144 | "disabled": false, 145 | "downloads_url": "https://api.github.com/repos/trufflehq/chonk/downloads", 146 | "events_url": "https://api.github.com/repos/trufflehq/chonk/events", 147 | "fork": false, 148 | "forks": 0, 149 | "forks_count": 0, 150 | "forks_url": "https://api.github.com/repos/trufflehq/chonk/forks", 151 | "full_name": "trufflehq/chonk", 152 | "git_commits_url": "https://api.github.com/repos/trufflehq/chonk/git/commits{/sha}", 153 | "git_refs_url": "https://api.github.com/repos/trufflehq/chonk/git/refs{/sha}", 154 | "git_tags_url": "https://api.github.com/repos/trufflehq/chonk/git/tags{/sha}", 155 | "git_url": "git://github.com/trufflehq/chonk.git", 156 | "has_discussions": false, 157 | "has_downloads": true, 158 | "has_issues": true, 159 | "has_pages": false, 160 | "has_projects": false, 161 | "has_wiki": false, 162 | "homepage": "", 163 | "hooks_url": "https://api.github.com/repos/trufflehq/chonk/hooks", 164 | "html_url": "https://github.com/trufflehq/chonk", 165 | "id": 560152876, 166 | "is_template": false, 167 | "issue_comment_url": "https://api.github.com/repos/trufflehq/chonk/issues/comments{/number}", 168 | "issue_events_url": "https://api.github.com/repos/trufflehq/chonk/issues/events{/number}", 169 | "issues_url": "https://api.github.com/repos/trufflehq/chonk/issues{/number}", 170 | "keys_url": "https://api.github.com/repos/trufflehq/chonk/keys{/key_id}", 171 | "labels_url": "https://api.github.com/repos/trufflehq/chonk/labels{/name}", 172 | "language": "JavaScript", 173 | "languages_url": "https://api.github.com/repos/trufflehq/chonk/languages", 174 | "license": null, 175 | "merge_commit_message": "PR_TITLE", 176 | "merge_commit_title": "MERGE_MESSAGE", 177 | "merges_url": "https://api.github.com/repos/trufflehq/chonk/merges", 178 | "milestones_url": "https://api.github.com/repos/trufflehq/chonk/milestones{/number}", 179 | "mirror_url": null, 180 | "name": "chonk", 181 | "node_id": "R_kgDOIWNBLA", 182 | "notifications_url": "https://api.github.com/repos/trufflehq/chonk/notifications{?since,all,participating}", 183 | "open_issues": 5, 184 | "open_issues_count": 5, 185 | "owner": { 186 | "avatar_url": "https://avatars.githubusercontent.com/u/76624237?v=4", 187 | "events_url": "https://api.github.com/users/trufflehq/events{/privacy}", 188 | "followers_url": "https://api.github.com/users/trufflehq/followers", 189 | "following_url": "https://api.github.com/users/trufflehq/following{/other_user}", 190 | "gists_url": "https://api.github.com/users/trufflehq/gists{/gist_id}", 191 | "gravatar_id": "", 192 | "html_url": "https://github.com/trufflehq", 193 | "id": 76624237, 194 | "login": "trufflehq", 195 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjc2NjI0MjM3", 196 | "organizations_url": "https://api.github.com/users/trufflehq/orgs", 197 | "received_events_url": "https://api.github.com/users/trufflehq/received_events", 198 | "repos_url": "https://api.github.com/users/trufflehq/repos", 199 | "site_admin": false, 200 | "starred_url": "https://api.github.com/users/trufflehq/starred{/owner}{/repo}", 201 | "subscriptions_url": "https://api.github.com/users/trufflehq/subscriptions", 202 | "type": "Organization", 203 | "url": "https://api.github.com/users/trufflehq" 204 | }, 205 | "private": true, 206 | "pulls_url": "https://api.github.com/repos/trufflehq/chonk/pulls{/number}", 207 | "pushed_at": "2023-07-04T01:46:28Z", 208 | "releases_url": "https://api.github.com/repos/trufflehq/chonk/releases{/id}", 209 | "size": 12722, 210 | "squash_merge_commit_message": "COMMIT_MESSAGES", 211 | "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", 212 | "ssh_url": "git@github.com:trufflehq/chonk.git", 213 | "stargazers_count": 2, 214 | "stargazers_url": "https://api.github.com/repos/trufflehq/chonk/stargazers", 215 | "statuses_url": "https://api.github.com/repos/trufflehq/chonk/statuses/{sha}", 216 | "subscribers_url": "https://api.github.com/repos/trufflehq/chonk/subscribers", 217 | "subscription_url": "https://api.github.com/repos/trufflehq/chonk/subscription", 218 | "svn_url": "https://github.com/trufflehq/chonk", 219 | "tags_url": "https://api.github.com/repos/trufflehq/chonk/tags", 220 | "teams_url": "https://api.github.com/repos/trufflehq/chonk/teams", 221 | "topics": [], 222 | "trees_url": "https://api.github.com/repos/trufflehq/chonk/git/trees{/sha}", 223 | "updated_at": "2023-06-09T21:26:41Z", 224 | "url": "https://api.github.com/repos/trufflehq/chonk", 225 | "use_squash_pr_title_as_default": false, 226 | "visibility": "private", 227 | "watchers": 2, 228 | "watchers_count": 2, 229 | "web_commit_signoff_required": false 230 | }, 231 | "sha": "34fa98ebb62e9d704a09c32bc990c7958ce07a32", 232 | "user": { 233 | "avatar_url": "https://avatars.githubusercontent.com/u/76624237?v=4", 234 | "events_url": "https://api.github.com/users/trufflehq/events{/privacy}", 235 | "followers_url": "https://api.github.com/users/trufflehq/followers", 236 | "following_url": "https://api.github.com/users/trufflehq/following{/other_user}", 237 | "gists_url": "https://api.github.com/users/trufflehq/gists{/gist_id}", 238 | "gravatar_id": "", 239 | "html_url": "https://github.com/trufflehq", 240 | "id": 76624237, 241 | "login": "trufflehq", 242 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjc2NjI0MjM3", 243 | "organizations_url": "https://api.github.com/users/trufflehq/orgs", 244 | "received_events_url": "https://api.github.com/users/trufflehq/received_events", 245 | "repos_url": "https://api.github.com/users/trufflehq/repos", 246 | "site_admin": false, 247 | "starred_url": "https://api.github.com/users/trufflehq/starred{/owner}{/repo}", 248 | "subscriptions_url": "https://api.github.com/users/trufflehq/subscriptions", 249 | "type": "Organization", 250 | "url": "https://api.github.com/users/trufflehq" 251 | } 252 | }, 253 | "body": null, 254 | "closed_at": null, 255 | "comments_url": "https://api.github.com/repos/trufflehq/chonk/issues/116/comments", 256 | "commits_url": "https://api.github.com/repos/trufflehq/chonk/pulls/116/commits", 257 | "created_at": "2023-07-04T01:46:28Z", 258 | "diff_url": "https://github.com/trufflehq/chonk/pull/116.diff", 259 | "draft": false, 260 | "head": { 261 | "label": "trufflehq:szc-site-styles", 262 | "ref": "szc-site-styles", 263 | "repo": { 264 | "allow_auto_merge": false, 265 | "allow_forking": false, 266 | "allow_merge_commit": false, 267 | "allow_rebase_merge": false, 268 | "allow_squash_merge": true, 269 | "allow_update_branch": false, 270 | "archive_url": "https://api.github.com/repos/trufflehq/chonk/{archive_format}{/ref}", 271 | "archived": false, 272 | "assignees_url": "https://api.github.com/repos/trufflehq/chonk/assignees{/user}", 273 | "blobs_url": "https://api.github.com/repos/trufflehq/chonk/git/blobs{/sha}", 274 | "branches_url": "https://api.github.com/repos/trufflehq/chonk/branches{/branch}", 275 | "clone_url": "https://github.com/trufflehq/chonk.git", 276 | "collaborators_url": "https://api.github.com/repos/trufflehq/chonk/collaborators{/collaborator}", 277 | "comments_url": "https://api.github.com/repos/trufflehq/chonk/comments{/number}", 278 | "commits_url": "https://api.github.com/repos/trufflehq/chonk/commits{/sha}", 279 | "compare_url": "https://api.github.com/repos/trufflehq/chonk/compare/{base}...{head}", 280 | "contents_url": "https://api.github.com/repos/trufflehq/chonk/contents/{+path}", 281 | "contributors_url": "https://api.github.com/repos/trufflehq/chonk/contributors", 282 | "created_at": "2022-10-31T21:07:22Z", 283 | "default_branch": "main", 284 | "delete_branch_on_merge": false, 285 | "deployments_url": "https://api.github.com/repos/trufflehq/chonk/deployments", 286 | "description": "all the services! so many codes! so much chonk!", 287 | "disabled": false, 288 | "downloads_url": "https://api.github.com/repos/trufflehq/chonk/downloads", 289 | "events_url": "https://api.github.com/repos/trufflehq/chonk/events", 290 | "fork": false, 291 | "forks": 0, 292 | "forks_count": 0, 293 | "forks_url": "https://api.github.com/repos/trufflehq/chonk/forks", 294 | "full_name": "trufflehq/chonk", 295 | "git_commits_url": "https://api.github.com/repos/trufflehq/chonk/git/commits{/sha}", 296 | "git_refs_url": "https://api.github.com/repos/trufflehq/chonk/git/refs{/sha}", 297 | "git_tags_url": "https://api.github.com/repos/trufflehq/chonk/git/tags{/sha}", 298 | "git_url": "git://github.com/trufflehq/chonk.git", 299 | "has_discussions": false, 300 | "has_downloads": true, 301 | "has_issues": true, 302 | "has_pages": false, 303 | "has_projects": false, 304 | "has_wiki": false, 305 | "homepage": "", 306 | "hooks_url": "https://api.github.com/repos/trufflehq/chonk/hooks", 307 | "html_url": "https://github.com/trufflehq/chonk", 308 | "id": 560152876, 309 | "is_template": false, 310 | "issue_comment_url": "https://api.github.com/repos/trufflehq/chonk/issues/comments{/number}", 311 | "issue_events_url": "https://api.github.com/repos/trufflehq/chonk/issues/events{/number}", 312 | "issues_url": "https://api.github.com/repos/trufflehq/chonk/issues{/number}", 313 | "keys_url": "https://api.github.com/repos/trufflehq/chonk/keys{/key_id}", 314 | "labels_url": "https://api.github.com/repos/trufflehq/chonk/labels{/name}", 315 | "language": "JavaScript", 316 | "languages_url": "https://api.github.com/repos/trufflehq/chonk/languages", 317 | "license": null, 318 | "merge_commit_message": "PR_TITLE", 319 | "merge_commit_title": "MERGE_MESSAGE", 320 | "merges_url": "https://api.github.com/repos/trufflehq/chonk/merges", 321 | "milestones_url": "https://api.github.com/repos/trufflehq/chonk/milestones{/number}", 322 | "mirror_url": null, 323 | "name": "chonk", 324 | "node_id": "R_kgDOIWNBLA", 325 | "notifications_url": "https://api.github.com/repos/trufflehq/chonk/notifications{?since,all,participating}", 326 | "open_issues": 5, 327 | "open_issues_count": 5, 328 | "owner": { 329 | "avatar_url": "https://avatars.githubusercontent.com/u/76624237?v=4", 330 | "events_url": "https://api.github.com/users/trufflehq/events{/privacy}", 331 | "followers_url": "https://api.github.com/users/trufflehq/followers", 332 | "following_url": "https://api.github.com/users/trufflehq/following{/other_user}", 333 | "gists_url": "https://api.github.com/users/trufflehq/gists{/gist_id}", 334 | "gravatar_id": "", 335 | "html_url": "https://github.com/trufflehq", 336 | "id": 76624237, 337 | "login": "trufflehq", 338 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjc2NjI0MjM3", 339 | "organizations_url": "https://api.github.com/users/trufflehq/orgs", 340 | "received_events_url": "https://api.github.com/users/trufflehq/received_events", 341 | "repos_url": "https://api.github.com/users/trufflehq/repos", 342 | "site_admin": false, 343 | "starred_url": "https://api.github.com/users/trufflehq/starred{/owner}{/repo}", 344 | "subscriptions_url": "https://api.github.com/users/trufflehq/subscriptions", 345 | "type": "Organization", 346 | "url": "https://api.github.com/users/trufflehq" 347 | }, 348 | "private": true, 349 | "pulls_url": "https://api.github.com/repos/trufflehq/chonk/pulls{/number}", 350 | "pushed_at": "2023-07-04T01:46:28Z", 351 | "releases_url": "https://api.github.com/repos/trufflehq/chonk/releases{/id}", 352 | "size": 12722, 353 | "squash_merge_commit_message": "COMMIT_MESSAGES", 354 | "squash_merge_commit_title": "COMMIT_OR_PR_TITLE", 355 | "ssh_url": "git@github.com:trufflehq/chonk.git", 356 | "stargazers_count": 2, 357 | "stargazers_url": "https://api.github.com/repos/trufflehq/chonk/stargazers", 358 | "statuses_url": "https://api.github.com/repos/trufflehq/chonk/statuses/{sha}", 359 | "subscribers_url": "https://api.github.com/repos/trufflehq/chonk/subscribers", 360 | "subscription_url": "https://api.github.com/repos/trufflehq/chonk/subscription", 361 | "svn_url": "https://github.com/trufflehq/chonk", 362 | "tags_url": "https://api.github.com/repos/trufflehq/chonk/tags", 363 | "teams_url": "https://api.github.com/repos/trufflehq/chonk/teams", 364 | "topics": [], 365 | "trees_url": "https://api.github.com/repos/trufflehq/chonk/git/trees{/sha}", 366 | "updated_at": "2023-06-09T21:26:41Z", 367 | "url": "https://api.github.com/repos/trufflehq/chonk", 368 | "use_squash_pr_title_as_default": false, 369 | "visibility": "private", 370 | "watchers": 2, 371 | "watchers_count": 2, 372 | "web_commit_signoff_required": false 373 | }, 374 | "sha": "c71e4dfab43e7ede9919e5adb51a717366c798cc", 375 | "user": { 376 | "avatar_url": "https://avatars.githubusercontent.com/u/76624237?v=4", 377 | "events_url": "https://api.github.com/users/trufflehq/events{/privacy}", 378 | "followers_url": "https://api.github.com/users/trufflehq/followers", 379 | "following_url": "https://api.github.com/users/trufflehq/following{/other_user}", 380 | "gists_url": "https://api.github.com/users/trufflehq/gists{/gist_id}", 381 | "gravatar_id": "", 382 | "html_url": "https://github.com/trufflehq", 383 | "id": 76624237, 384 | "login": "trufflehq", 385 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjc2NjI0MjM3", 386 | "organizations_url": "https://api.github.com/users/trufflehq/orgs", 387 | "received_events_url": "https://api.github.com/users/trufflehq/received_events", 388 | "repos_url": "https://api.github.com/users/trufflehq/repos", 389 | "site_admin": false, 390 | "starred_url": "https://api.github.com/users/trufflehq/starred{/owner}{/repo}", 391 | "subscriptions_url": "https://api.github.com/users/trufflehq/subscriptions", 392 | "type": "Organization", 393 | "url": "https://api.github.com/users/trufflehq" 394 | } 395 | }, 396 | "html_url": "https://github.com/trufflehq/chonk/pull/116", 397 | "id": 1418846518, 398 | "issue_url": "https://api.github.com/repos/trufflehq/chonk/issues/116", 399 | "labels": [], 400 | "locked": false, 401 | "merge_commit_sha": null, 402 | "merged_at": null, 403 | "milestone": null, 404 | "node_id": "PR_kwDOIWNBLM5UkeE2", 405 | "number": 116, 406 | "patch_url": "https://github.com/trufflehq/chonk/pull/116.patch", 407 | "requested_reviewers": [], 408 | "requested_teams": [], 409 | "review_comment_url": "https://api.github.com/repos/trufflehq/chonk/pulls/comments{/number}", 410 | "review_comments_url": "https://api.github.com/repos/trufflehq/chonk/pulls/116/comments", 411 | "state": "open", 412 | "statuses_url": "https://api.github.com/repos/trufflehq/chonk/statuses/c71e4dfab43e7ede9919e5adb51a717366c798cc", 413 | "title": "App.truffle.vip styles for profile page and community page", 414 | "updated_at": "2023-07-04T07:52:40Z", 415 | "url": "https://api.github.com/repos/trufflehq/chonk/pulls/116", 416 | "user": { 417 | "avatar_url": "https://avatars.githubusercontent.com/u/70922464?v=4", 418 | "events_url": "https://api.github.com/users/shanecranor/events{/privacy}", 419 | "followers_url": "https://api.github.com/users/shanecranor/followers", 420 | "following_url": "https://api.github.com/users/shanecranor/following{/other_user}", 421 | "gists_url": "https://api.github.com/users/shanecranor/gists{/gist_id}", 422 | "gravatar_id": "", 423 | "html_url": "https://github.com/shanecranor", 424 | "id": 70922464, 425 | "login": "shanecranor", 426 | "node_id": "MDQ6VXNlcjcwOTIyNDY0", 427 | "organizations_url": "https://api.github.com/users/shanecranor/orgs", 428 | "received_events_url": "https://api.github.com/users/shanecranor/received_events", 429 | "repos_url": "https://api.github.com/users/shanecranor/repos", 430 | "site_admin": false, 431 | "starred_url": "https://api.github.com/users/shanecranor/starred{/owner}{/repo}", 432 | "subscriptions_url": "https://api.github.com/users/shanecranor/subscriptions", 433 | "type": "User", 434 | "url": "https://api.github.com/users/shanecranor" 435 | } 436 | }, 437 | "repository": { 438 | "allow_forking": false, 439 | "archive_url": "https://api.github.com/repos/trufflehq/chonk/{archive_format}{/ref}", 440 | "archived": false, 441 | "assignees_url": "https://api.github.com/repos/trufflehq/chonk/assignees{/user}", 442 | "blobs_url": "https://api.github.com/repos/trufflehq/chonk/git/blobs{/sha}", 443 | "branches_url": "https://api.github.com/repos/trufflehq/chonk/branches{/branch}", 444 | "clone_url": "https://github.com/trufflehq/chonk.git", 445 | "collaborators_url": "https://api.github.com/repos/trufflehq/chonk/collaborators{/collaborator}", 446 | "comments_url": "https://api.github.com/repos/trufflehq/chonk/comments{/number}", 447 | "commits_url": "https://api.github.com/repos/trufflehq/chonk/commits{/sha}", 448 | "compare_url": "https://api.github.com/repos/trufflehq/chonk/compare/{base}...{head}", 449 | "contents_url": "https://api.github.com/repos/trufflehq/chonk/contents/{+path}", 450 | "contributors_url": "https://api.github.com/repos/trufflehq/chonk/contributors", 451 | "created_at": "2022-10-31T21:07:22Z", 452 | "default_branch": "main", 453 | "deployments_url": "https://api.github.com/repos/trufflehq/chonk/deployments", 454 | "description": "all the services! so many codes! so much chonk!", 455 | "disabled": false, 456 | "downloads_url": "https://api.github.com/repos/trufflehq/chonk/downloads", 457 | "events_url": "https://api.github.com/repos/trufflehq/chonk/events", 458 | "fork": false, 459 | "forks": 0, 460 | "forks_count": 0, 461 | "forks_url": "https://api.github.com/repos/trufflehq/chonk/forks", 462 | "full_name": "trufflehq/chonk", 463 | "git_commits_url": "https://api.github.com/repos/trufflehq/chonk/git/commits{/sha}", 464 | "git_refs_url": "https://api.github.com/repos/trufflehq/chonk/git/refs{/sha}", 465 | "git_tags_url": "https://api.github.com/repos/trufflehq/chonk/git/tags{/sha}", 466 | "git_url": "git://github.com/trufflehq/chonk.git", 467 | "has_discussions": false, 468 | "has_downloads": true, 469 | "has_issues": true, 470 | "has_pages": false, 471 | "has_projects": false, 472 | "has_wiki": false, 473 | "homepage": "", 474 | "hooks_url": "https://api.github.com/repos/trufflehq/chonk/hooks", 475 | "html_url": "https://github.com/trufflehq/chonk", 476 | "id": 560152876, 477 | "is_template": false, 478 | "issue_comment_url": "https://api.github.com/repos/trufflehq/chonk/issues/comments{/number}", 479 | "issue_events_url": "https://api.github.com/repos/trufflehq/chonk/issues/events{/number}", 480 | "issues_url": "https://api.github.com/repos/trufflehq/chonk/issues{/number}", 481 | "keys_url": "https://api.github.com/repos/trufflehq/chonk/keys{/key_id}", 482 | "labels_url": "https://api.github.com/repos/trufflehq/chonk/labels{/name}", 483 | "language": "JavaScript", 484 | "languages_url": "https://api.github.com/repos/trufflehq/chonk/languages", 485 | "license": null, 486 | "merges_url": "https://api.github.com/repos/trufflehq/chonk/merges", 487 | "milestones_url": "https://api.github.com/repos/trufflehq/chonk/milestones{/number}", 488 | "mirror_url": null, 489 | "name": "chonk", 490 | "node_id": "R_kgDOIWNBLA", 491 | "notifications_url": "https://api.github.com/repos/trufflehq/chonk/notifications{?since,all,participating}", 492 | "open_issues": 5, 493 | "open_issues_count": 5, 494 | "owner": { 495 | "avatar_url": "https://avatars.githubusercontent.com/u/76624237?v=4", 496 | "events_url": "https://api.github.com/users/trufflehq/events{/privacy}", 497 | "followers_url": "https://api.github.com/users/trufflehq/followers", 498 | "following_url": "https://api.github.com/users/trufflehq/following{/other_user}", 499 | "gists_url": "https://api.github.com/users/trufflehq/gists{/gist_id}", 500 | "gravatar_id": "", 501 | "html_url": "https://github.com/trufflehq", 502 | "id": 76624237, 503 | "login": "trufflehq", 504 | "node_id": "MDEyOk9yZ2FuaXphdGlvbjc2NjI0MjM3", 505 | "organizations_url": "https://api.github.com/users/trufflehq/orgs", 506 | "received_events_url": "https://api.github.com/users/trufflehq/received_events", 507 | "repos_url": "https://api.github.com/users/trufflehq/repos", 508 | "site_admin": false, 509 | "starred_url": "https://api.github.com/users/trufflehq/starred{/owner}{/repo}", 510 | "subscriptions_url": "https://api.github.com/users/trufflehq/subscriptions", 511 | "type": "Organization", 512 | "url": "https://api.github.com/users/trufflehq" 513 | }, 514 | "private": true, 515 | "pulls_url": "https://api.github.com/repos/trufflehq/chonk/pulls{/number}", 516 | "pushed_at": "2023-07-04T01:46:28Z", 517 | "releases_url": "https://api.github.com/repos/trufflehq/chonk/releases{/id}", 518 | "size": 12722, 519 | "ssh_url": "git@github.com:trufflehq/chonk.git", 520 | "stargazers_count": 2, 521 | "stargazers_url": "https://api.github.com/repos/trufflehq/chonk/stargazers", 522 | "statuses_url": "https://api.github.com/repos/trufflehq/chonk/statuses/{sha}", 523 | "subscribers_url": "https://api.github.com/repos/trufflehq/chonk/subscribers", 524 | "subscription_url": "https://api.github.com/repos/trufflehq/chonk/subscription", 525 | "svn_url": "https://github.com/trufflehq/chonk", 526 | "tags_url": "https://api.github.com/repos/trufflehq/chonk/tags", 527 | "teams_url": "https://api.github.com/repos/trufflehq/chonk/teams", 528 | "topics": [], 529 | "trees_url": "https://api.github.com/repos/trufflehq/chonk/git/trees{/sha}", 530 | "updated_at": "2023-06-09T21:26:41Z", 531 | "url": "https://api.github.com/repos/trufflehq/chonk", 532 | "visibility": "private", 533 | "watchers": 2, 534 | "watchers_count": 2, 535 | "web_commit_signoff_required": false 536 | }, 537 | "sender": { 538 | "avatar_url": "https://avatars.githubusercontent.com/u/1271767?v=4", 539 | "events_url": "https://api.github.com/users/austinhallock/events{/privacy}", 540 | "followers_url": "https://api.github.com/users/austinhallock/followers", 541 | "following_url": "https://api.github.com/users/austinhallock/following{/other_user}", 542 | "gists_url": "https://api.github.com/users/austinhallock/gists{/gist_id}", 543 | "gravatar_id": "", 544 | "html_url": "https://github.com/austinhallock", 545 | "id": 1271767, 546 | "login": "austinhallock", 547 | "node_id": "MDQ6VXNlcjEyNzE3Njc=", 548 | "organizations_url": "https://api.github.com/users/austinhallock/orgs", 549 | "received_events_url": "https://api.github.com/users/austinhallock/received_events", 550 | "repos_url": "https://api.github.com/users/austinhallock/repos", 551 | "site_admin": false, 552 | "starred_url": "https://api.github.com/users/austinhallock/starred{/owner}{/repo}", 553 | "subscriptions_url": "https://api.github.com/users/austinhallock/subscriptions", 554 | "type": "User", 555 | "url": "https://api.github.com/users/austinhallock" 556 | } 557 | } 558 | -------------------------------------------------------------------------------- /chuckle-github/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod pull_request_review_comment; 2 | 3 | const CHUCKLE_USER_AGENT: &str = concat!( 4 | env!("CARGO_PKG_NAME"), 5 | "/", 6 | env!("CARGO_PKG_VERSION"), 7 | " (", 8 | env!("CARGO_PKG_HOMEPAGE"), 9 | ")" 10 | ); 11 | 12 | /// Fetches a raw file fro githubusercontent.com 13 | pub async fn fetch_raw_file( 14 | token: String, 15 | owner: String, 16 | name: String, 17 | commit: String, 18 | path: String, 19 | ) -> anyhow::Result { 20 | let file_url = 21 | format!("https://api.github.com/repos/{owner}/{name}/contents/{path}?ref={commit}",); 22 | 23 | let client = reqwest::Client::new(); 24 | let resp = client 25 | .get(&file_url) 26 | .header("Authorization", format!("token {token}")) 27 | .header("User-Agent", CHUCKLE_USER_AGENT) 28 | .header("Accept", "application/vnd.github.raw") 29 | .send() 30 | .await?; 31 | 32 | let body = resp.text().await?; 33 | 34 | Ok(body) 35 | } 36 | 37 | #[cfg(test)] 38 | mod test { 39 | #[tokio::test] 40 | async fn test_fetch_raw_file() { 41 | let token = std::env::var("GITHUB_TOKEN").unwrap(); 42 | let owner = std::env::var("GITHUB_OWNER").unwrap_or("trufflehq".into()); 43 | let name = std::env::var("GITHUB_REPO").unwrap_or("chuckle".into()); 44 | let commit = std::env::var("GITHUB_COMMIT").unwrap_or("HEAD".into()); 45 | let path = std::env::var("GITHUB_PATH").unwrap_or("README.md".into()); 46 | 47 | let file = super::fetch_raw_file(token, owner, name, commit, path).await; 48 | assert!(file.is_ok()); 49 | let file = file.unwrap(); 50 | 51 | assert!(!file.is_empty()); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /chuckle-github/src/pull_request_review_comment.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize)] 4 | pub struct PullRequestReviewComment { 5 | pub action: String, 6 | pub comment: Comment, 7 | pub pull_request: PullRequest, 8 | pub repository: Repo, 9 | pub organization: Organization, 10 | pub sender: Sender, 11 | } 12 | 13 | #[derive(Debug, Clone, Serialize, Deserialize)] 14 | pub struct Comment { 15 | pub url: String, 16 | pub pull_request_review_id: i64, 17 | pub id: i64, 18 | pub node_id: String, 19 | pub diff_hunk: String, 20 | pub path: String, 21 | pub commit_id: String, 22 | pub original_commit_id: String, 23 | pub user: Sender, 24 | pub body: String, 25 | pub created_at: String, 26 | pub updated_at: String, 27 | pub html_url: String, 28 | pub pull_request_url: String, 29 | pub author_association: String, 30 | #[serde(rename = "_links")] 31 | pub links: CommentLinks, 32 | pub reactions: Reactions, 33 | pub start_line: Option, 34 | pub original_start_line: Option, 35 | pub start_side: Option, 36 | pub line: Option, 37 | pub original_line: i64, 38 | pub side: String, 39 | pub original_position: i64, 40 | pub position: i64, 41 | pub subject_type: String, 42 | } 43 | 44 | #[derive(Debug, Clone, Serialize, Deserialize)] 45 | pub struct CommentLinks { 46 | #[serde(rename = "self")] 47 | pub links_self: Html, 48 | pub html: Html, 49 | pub pull_request: Html, 50 | } 51 | 52 | #[derive(Debug, Clone, Serialize, Deserialize)] 53 | pub struct Html { 54 | pub href: String, 55 | } 56 | 57 | #[derive(Debug, Clone, Serialize, Deserialize)] 58 | pub struct Reactions { 59 | pub url: String, 60 | pub total_count: i64, 61 | #[serde(rename = "+1")] 62 | pub the_1: i64, 63 | #[serde(rename = "-1")] 64 | pub reactions_1: i64, 65 | pub laugh: i64, 66 | pub hooray: i64, 67 | pub confused: i64, 68 | pub heart: i64, 69 | pub rocket: i64, 70 | pub eyes: i64, 71 | } 72 | 73 | #[derive(Debug, Clone, Serialize, Deserialize)] 74 | pub struct Sender { 75 | pub login: String, 76 | pub id: i64, 77 | pub node_id: String, 78 | pub avatar_url: String, 79 | pub gravatar_id: String, 80 | pub url: String, 81 | pub html_url: String, 82 | pub followers_url: String, 83 | pub following_url: String, 84 | pub gists_url: String, 85 | pub starred_url: String, 86 | pub subscriptions_url: String, 87 | pub organizations_url: String, 88 | pub repos_url: String, 89 | pub events_url: String, 90 | pub received_events_url: String, 91 | #[serde(rename = "type")] 92 | pub sender_type: String, 93 | pub site_admin: bool, 94 | } 95 | 96 | #[derive(Debug, Clone, Serialize, Deserialize)] 97 | pub struct Organization { 98 | pub login: String, 99 | pub id: i64, 100 | pub node_id: String, 101 | pub url: String, 102 | pub repos_url: String, 103 | pub events_url: String, 104 | pub hooks_url: String, 105 | pub issues_url: String, 106 | pub members_url: String, 107 | pub public_members_url: String, 108 | pub avatar_url: String, 109 | pub description: String, 110 | } 111 | 112 | #[derive(Debug, Clone, Serialize, Deserialize)] 113 | pub struct PullRequest { 114 | pub url: String, 115 | pub id: i64, 116 | pub node_id: String, 117 | pub html_url: String, 118 | pub diff_url: String, 119 | pub patch_url: String, 120 | pub issue_url: String, 121 | pub number: i64, 122 | pub state: String, 123 | pub locked: bool, 124 | pub title: String, 125 | pub user: Sender, 126 | pub body: Option, 127 | pub created_at: String, 128 | pub updated_at: String, 129 | pub closed_at: Option, 130 | pub merged_at: Option, 131 | pub merge_commit_sha: Option, 132 | pub assignee: Option, 133 | pub assignees: Vec>, 134 | pub requested_reviewers: Vec>, 135 | pub requested_teams: Vec>, 136 | pub labels: Vec>, 137 | pub milestone: Option, 138 | pub draft: bool, 139 | pub commits_url: String, 140 | pub review_comments_url: String, 141 | pub review_comment_url: String, 142 | pub comments_url: String, 143 | pub statuses_url: String, 144 | pub head: Base, 145 | pub base: Base, 146 | #[serde(rename = "_links")] 147 | pub links: PullRequestLinks, 148 | pub author_association: String, 149 | pub auto_merge: Option, 150 | pub active_lock_reason: Option, 151 | } 152 | 153 | #[derive(Debug, Clone, Serialize, Deserialize)] 154 | pub struct Base { 155 | pub label: String, 156 | #[serde(rename = "ref")] 157 | pub base_ref: String, 158 | pub sha: String, 159 | pub user: Sender, 160 | pub repo: Repo, 161 | } 162 | 163 | #[derive(Debug, Clone, Serialize, Deserialize)] 164 | pub struct Repo { 165 | pub id: i64, 166 | pub node_id: String, 167 | pub name: String, 168 | pub full_name: String, 169 | pub private: bool, 170 | pub owner: Sender, 171 | pub html_url: String, 172 | pub description: String, 173 | pub fork: bool, 174 | pub url: String, 175 | pub forks_url: String, 176 | pub keys_url: String, 177 | pub collaborators_url: String, 178 | pub teams_url: String, 179 | pub hooks_url: String, 180 | pub issue_events_url: String, 181 | pub events_url: String, 182 | pub assignees_url: String, 183 | pub branches_url: String, 184 | pub tags_url: String, 185 | pub blobs_url: String, 186 | pub git_tags_url: String, 187 | pub git_refs_url: String, 188 | pub trees_url: String, 189 | pub statuses_url: String, 190 | pub languages_url: String, 191 | pub stargazers_url: String, 192 | pub contributors_url: String, 193 | pub subscribers_url: String, 194 | pub subscription_url: String, 195 | pub commits_url: String, 196 | pub git_commits_url: String, 197 | pub comments_url: String, 198 | pub issue_comment_url: String, 199 | pub contents_url: String, 200 | pub compare_url: String, 201 | pub merges_url: String, 202 | pub archive_url: String, 203 | pub downloads_url: String, 204 | pub issues_url: String, 205 | pub pulls_url: String, 206 | pub milestones_url: String, 207 | pub notifications_url: String, 208 | pub labels_url: String, 209 | pub releases_url: String, 210 | pub deployments_url: String, 211 | pub created_at: String, 212 | pub updated_at: String, 213 | pub pushed_at: String, 214 | pub git_url: String, 215 | pub ssh_url: String, 216 | pub clone_url: String, 217 | pub svn_url: String, 218 | pub homepage: Option, 219 | pub size: i64, 220 | pub stargazers_count: i64, 221 | pub watchers_count: i64, 222 | pub language: String, 223 | pub has_issues: bool, 224 | pub has_projects: bool, 225 | pub has_downloads: bool, 226 | pub has_wiki: bool, 227 | pub has_pages: bool, 228 | pub has_discussions: bool, 229 | pub forks_count: i64, 230 | pub mirror_url: Option, 231 | pub archived: bool, 232 | pub disabled: bool, 233 | pub open_issues_count: i64, 234 | pub license: Option, 235 | pub allow_forking: bool, 236 | pub is_template: bool, 237 | pub web_commit_signoff_required: bool, 238 | pub topics: Vec>, 239 | pub visibility: String, 240 | pub forks: i64, 241 | pub open_issues: i64, 242 | pub watchers: i64, 243 | pub default_branch: String, 244 | pub allow_squash_merge: Option, 245 | pub allow_merge_commit: Option, 246 | pub allow_rebase_merge: Option, 247 | pub allow_auto_merge: Option, 248 | pub delete_branch_on_merge: Option, 249 | pub allow_update_branch: Option, 250 | pub use_squash_pr_title_as_default: Option, 251 | pub squash_merge_commit_message: Option, 252 | pub squash_merge_commit_title: Option, 253 | pub merge_commit_message: Option, 254 | pub merge_commit_title: Option, 255 | } 256 | 257 | #[derive(Debug, Clone, Serialize, Deserialize)] 258 | pub struct PullRequestLinks { 259 | #[serde(rename = "self")] 260 | pub links_self: Html, 261 | pub html: Html, 262 | pub issue: Html, 263 | pub comments: Html, 264 | pub review_comments: Html, 265 | pub review_comment: Html, 266 | pub commits: Html, 267 | pub statuses: Html, 268 | } 269 | 270 | #[cfg(test)] 271 | mod tests { 272 | use format_serde_error::SerdeError; 273 | 274 | use crate::pull_request_review_comment::PullRequestReviewComment; 275 | 276 | #[test] 277 | fn test_parse() -> Result<(), anyhow::Error> { 278 | static DATA: &[u8] = include_bytes!(concat!( 279 | env!("CARGO_MANIFEST_DIR"), 280 | "/data/pull_request_review_comment.json" 281 | )); 282 | 283 | let data_string = String::from_utf8_lossy(DATA); 284 | 285 | let data = serde_json::from_slice::(DATA) 286 | .map_err(|err| SerdeError::new(data_string.to_string(), err))?; 287 | // assert!(res.is_ok()); 288 | 289 | // let data = res.unwrap(); 290 | assert_eq!(data.action, "created"); 291 | assert_eq!(data.comment.user.login, "austinhallock"); 292 | assert_eq!(data.comment.author_association, "CONTRIBUTOR"); 293 | assert_eq!(data.pull_request.number, 116); 294 | assert_eq!(data.pull_request.base.base_ref, "main"); 295 | 296 | Ok(()) 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /chuckle-http/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chuckle-http" 3 | description = "A HTTP server for Chuckle" 4 | version = { workspace = true } 5 | license = { workspace = true } 6 | edition = { workspace = true } 7 | authors = { workspace = true } 8 | repository = { workspace = true } 9 | homepage = { workspace = true } 10 | 11 | [dependencies] 12 | # core dependencies 13 | chuckle-github = { workspace = true } 14 | chuckle-interactions = { workspace = true } 15 | chuckle-util = { workspace = true } 16 | 17 | anyhow = { workspace = true } 18 | axum = { workspace = true } 19 | github-webhooks = { git = "https://github.com/trufflehq/github-webhooks.git", features = ["axum"] } 20 | once_cell = { workspace = true } 21 | ring = "0.16" 22 | serde = { workspace = true } 23 | serde_json = { workspace = true } 24 | sqlx = { workspace = true } 25 | thiserror = "1.0.48" 26 | tower = "0.4" 27 | tower-http = { version = "0.4", features = ["trace"] } 28 | tracing = { workspace = true } 29 | twilight-model = { workspace = true } 30 | twilight-util = { workspace = true } 31 | 32 | [dev-dependencies] 33 | tokio = { version = "1", features = ["macros"] } 34 | -------------------------------------------------------------------------------- /chuckle-http/src/lib.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use axum::Router; 3 | use chuckle_util::{ChuckleState, CONFIG}; 4 | use tower::ServiceBuilder; 5 | use tower_http::trace::TraceLayer; 6 | 7 | mod routes; 8 | mod util; 9 | 10 | pub use util::error::{Error, ResultExt}; 11 | 12 | use self::routes::{status, webhooks}; 13 | 14 | pub type Result = std::result::Result; 15 | 16 | pub async fn serve(state: ChuckleState) -> anyhow::Result<()> { 17 | let app = Router::new() 18 | .merge(routes(state)) 19 | .layer(ServiceBuilder::new().layer(TraceLayer::new_for_http())); 20 | 21 | tracing::info!("Server running on http://172.30.0.1:{}", CONFIG.port); 22 | 23 | axum::Server::bind(&format!("0.0.0.0:{}", CONFIG.port).parse()?) 24 | .serve(app.into_make_service()) 25 | .await 26 | .context("Server crashed") 27 | } 28 | 29 | fn routes(state: ChuckleState) -> Router<()> { 30 | Router::new() 31 | .nest("/api", webhooks::router().merge(status::router())) 32 | .with_state(state) 33 | } 34 | -------------------------------------------------------------------------------- /chuckle-http/src/routes/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod status; 2 | pub mod webhooks; 3 | -------------------------------------------------------------------------------- /chuckle-http/src/routes/status.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | http::StatusCode, 3 | response::{IntoResponse, Response}, 4 | routing::any, 5 | Router, 6 | }; 7 | use chuckle_util::ChuckleState; 8 | 9 | use crate::Result; 10 | 11 | pub fn router() -> Router { 12 | Router::new().route("/status", any(status)) 13 | } 14 | 15 | #[axum::debug_handler] 16 | async fn status() -> Result { 17 | Ok(StatusCode::OK.into_response()) 18 | } 19 | -------------------------------------------------------------------------------- /chuckle-http/src/routes/webhooks.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use crate::Result; 4 | use axum::{ 5 | extract::State, 6 | http::StatusCode, 7 | response::{IntoResponse, Response}, 8 | routing::post, 9 | Router, 10 | }; 11 | use chuckle_github::{fetch_raw_file, pull_request_review_comment::PullRequestReviewComment}; 12 | use chuckle_util::{ChuckleState, CONFIG}; 13 | use github_webhooks::common::GithubWebhook; 14 | use once_cell::sync::Lazy; 15 | use ring::hmac; 16 | use twilight_model::id::{marker::ChannelMarker, Id}; 17 | 18 | pub fn router() -> Router { 19 | Router::new().route("/webhooks/github", post(handle_webhook)) 20 | } 21 | 22 | static KEY: Lazy = 23 | Lazy::new(|| hmac::Key::new(hmac::HMAC_SHA256, CONFIG.github_webhook_secret.as_bytes())); 24 | 25 | #[axum::debug_handler] 26 | async fn handle_webhook( 27 | State(state): State, 28 | webhook: GithubWebhook, 29 | ) -> Result { 30 | let event = match webhook.to_event(&KEY) { 31 | Ok(e) => e, 32 | Err(_) => return Ok(StatusCode::BAD_REQUEST.into_response()), 33 | }; 34 | 35 | tracing::info!("{:#?}", serde_json::to_string_pretty(&event).unwrap()); 36 | 37 | if webhook.event_type.as_str() == "pull_request_review_comment" { 38 | let data: PullRequestReviewComment = serde_json::from_value(event).unwrap(); 39 | 40 | let _ = handle_pr_review_comment(state, data).await; 41 | }; 42 | 43 | Ok(StatusCode::OK.into_response()) 44 | } 45 | 46 | async fn code_block(data: PullRequestReviewComment) -> anyhow::Result { 47 | let start_line = data 48 | .comment 49 | .start_line 50 | .and_then(|x| x.as_i64()) 51 | .or(Some(data.comment.original_line)); 52 | let end_line = data.comment.line.unwrap_or(data.comment.original_line); 53 | 54 | let content = fetch_raw_file( 55 | CONFIG.github_access_token.clone(), 56 | data.repository.owner.login, 57 | data.repository.name, 58 | data.comment.commit_id, 59 | data.comment.path.clone(), 60 | ) 61 | .await?; 62 | 63 | let (skip, take) = if start_line.is_none() || start_line.unwrap() == end_line { 64 | // only one line was commented on 65 | // so, take the line noted in `end_line`, and UP TO the four lines before it 66 | (end_line as usize - 1, 5_usize) 67 | } else { 68 | ( 69 | start_line.unwrap() as usize - 1, 70 | end_line as usize - start_line.unwrap() as usize + 1, 71 | ) 72 | }; 73 | 74 | let lines = content 75 | .lines() 76 | .skip(skip) 77 | .take(take) 78 | .collect::>() 79 | .join("\n"); 80 | 81 | let ext = data 82 | .comment 83 | .path 84 | .split('.') 85 | .last() 86 | .map(|x| x.to_lowercase()) 87 | .unwrap_or_else(|| "txt".to_string()); 88 | 89 | let block = format!("```{}\n{}\n```", ext, lines); 90 | 91 | Ok(block) 92 | } 93 | 94 | async fn handle_pr_review_comment( 95 | state: ChuckleState, 96 | data: PullRequestReviewComment, 97 | ) -> Result { 98 | let output = sqlx::query!( 99 | "select * from pr_review_output where pr_number = $1 and repo_owner = $2 and repo = $3;", 100 | data.pull_request.number as i32, 101 | data.repository.owner.login, 102 | data.repository.name 103 | ) 104 | .fetch_optional(&state.db) 105 | .await?; 106 | 107 | if output.is_none() { 108 | return Ok(StatusCode::OK.into_response()); 109 | } 110 | let output = output.unwrap(); 111 | 112 | let author = sqlx::query!( 113 | r#"select * from "user" where github_id = $1"#, 114 | data.comment.user.id as i32 115 | ) 116 | .fetch_optional(&state.db) 117 | .await?; 118 | 119 | let user_string = match author { 120 | Some(user) => format!("<@{}>", user.discord_id.unwrap()), 121 | None => "Unknown".to_string(), 122 | }; 123 | 124 | let codeblock = code_block(data.clone()) 125 | .await 126 | .unwrap_or_else(|_| String::from("")); 127 | 128 | let header = format!( 129 | "### [New Comment](<{}>) from {}", 130 | data.comment.links.html.href, user_string 131 | ); 132 | let file_url = format!( 133 | "https://github.com/{}/{}/blob/{}/{}", 134 | data.repository.owner.login, 135 | data.repository.name, 136 | data.pull_request.head.base_ref, 137 | data.comment.path, 138 | ); 139 | 140 | let mut subheader = format!("[`{}`]({})", data.comment.path, file_url); 141 | if let (Some(start), Some(end)) = ( 142 | data.comment.start_line.and_then(|x| x.as_i64()), 143 | data.comment.line, 144 | ) { 145 | let comment = format!(" `(L{}-{})`", start, end); 146 | subheader.push_str(&comment); 147 | } 148 | 149 | let comment = data.comment.body; 150 | let content = format!("{header}\n{comment}\n\n{subheader}\n{codeblock}"); 151 | 152 | let thread_id = Id::::from_str(&output.thread_id).unwrap(); 153 | let msg = state 154 | .http_client 155 | .create_message(thread_id) 156 | .content(&content) 157 | .unwrap() 158 | .allowed_mentions(None); 159 | 160 | let res = msg.await.unwrap(); 161 | tracing::debug!("{:#?}", res); 162 | 163 | Ok(StatusCode::OK.into_response()) 164 | } 165 | -------------------------------------------------------------------------------- /chuckle-http/src/util/error.rs: -------------------------------------------------------------------------------- 1 | use axum::http::header::WWW_AUTHENTICATE; 2 | use axum::http::{HeaderMap, HeaderValue, StatusCode}; 3 | use axum::response::{IntoResponse, Response}; 4 | use axum::Json; 5 | use sqlx::error::DatabaseError; 6 | use std::borrow::Cow; 7 | use std::collections::HashMap; 8 | 9 | /// A common error type that can be used throughout the API. 10 | /// 11 | /// Can be returned in a `Result` from an API handler function. 12 | /// 13 | /// For convenience, this represents both API errors as well as internal recoverable errors, 14 | /// and maps them to appropriate status codes along with at least a minimally useful error 15 | /// message in a plain text body, or a JSON body in the case of `UnprocessableEntity`. 16 | #[derive(thiserror::Error, Debug)] 17 | pub enum Error { 18 | /// Return `401 Unauthorized` 19 | #[error("authentication required")] 20 | Unauthorized, 21 | 22 | /// Return `403 Forbidden` 23 | #[error("user may not perform that action")] 24 | Forbidden, 25 | 26 | /// Return `404 Not Found` 27 | #[error("request path not found")] 28 | NotFound, 29 | 30 | /// Return `422 Unprocessable Entity` 31 | /// 32 | /// This also serializes the `errors` map to JSON to satisfy the requirement for 33 | /// `422 Unprocessable Entity` errors in the Realworld spec: 34 | /// https://realworld-docs.netlify.app/docs/specs/backend-specs/error-handling 35 | /// 36 | /// For a good API, the other status codes should also ideally map to some sort of JSON body 37 | /// that the frontend can deal with, but I do admit sometimes I've just gotten lazy and 38 | /// returned a plain error message if there were few enough error modes for a route 39 | /// that the frontend could infer the error from the status code alone. 40 | #[error("error in the request body")] 41 | UnprocessableEntity { 42 | errors: HashMap, Vec>>, 43 | }, 44 | 45 | /// Automatically return `500 Internal Server Error` on a `sqlx::Error`. 46 | /// 47 | /// Via the generated `From for Error` impl, 48 | /// this allows using `?` on database calls in handler functions without a manual mapping step. 49 | /// 50 | /// I highly recommend creating an error type like this if only to make handler function code 51 | /// nicer; code in Actix-web projects that we started before I settled on this pattern is 52 | /// filled with `.map_err(ErrInternalServerError)?` which is a *ton* of unnecessary noise. 53 | /// 54 | /// The actual error message isn't returned to the client for security reasons. 55 | /// It should be logged instead. 56 | /// 57 | /// Note that this could also contain database constraint errors, which should usually 58 | /// be transformed into client errors (e.g. `422 Unprocessable Entity` or `409 Conflict`). 59 | /// See `ResultExt` below for a convenient way to do this. 60 | #[error("an error occurred with the database")] 61 | Sqlx(#[from] sqlx::Error), 62 | 63 | /// Return `500 Internal Server Error` on a `anyhow::Error`. 64 | /// 65 | /// `anyhow::Error` is used in a few places to capture context and backtraces 66 | /// on unrecoverable (but technically non-fatal) errors which could be highly useful for 67 | /// debugging. We use it a lot in our code for background tasks or making API calls 68 | /// to external services so we can use `.context()` to refine the logged error. 69 | /// 70 | /// Via the generated `From for Error` impl, this allows the 71 | /// use of `?` in handler functions to automatically convert `anyhow::Error` into a response. 72 | /// 73 | /// Like with `Error::Sqlx`, the actual error message is not returned to the client 74 | /// for security reasons. 75 | #[error("an internal server error occurred")] 76 | Anyhow(#[from] anyhow::Error), 77 | } 78 | 79 | impl Error { 80 | /// Convenient constructor for `Error::UnprocessableEntity`. 81 | /// 82 | /// Multiple for the same key are collected into a list for that key. 83 | /// 84 | /// Try "Go to Usage" in an IDE for examples. 85 | pub fn unprocessable_entity(errors: impl IntoIterator) -> Self 86 | where 87 | K: Into>, 88 | V: Into>, 89 | { 90 | let mut error_map = HashMap::new(); 91 | 92 | for (key, val) in errors { 93 | error_map 94 | .entry(key.into()) 95 | .or_insert_with(Vec::new) 96 | .push(val.into()); 97 | } 98 | 99 | Self::UnprocessableEntity { errors: error_map } 100 | } 101 | 102 | fn status_code(&self) -> StatusCode { 103 | match self { 104 | Self::Unauthorized => StatusCode::UNAUTHORIZED, 105 | Self::Forbidden => StatusCode::FORBIDDEN, 106 | Self::NotFound => StatusCode::NOT_FOUND, 107 | Self::UnprocessableEntity { .. } => StatusCode::UNPROCESSABLE_ENTITY, 108 | Self::Sqlx(_) | Self::Anyhow(_) => StatusCode::INTERNAL_SERVER_ERROR, 109 | } 110 | } 111 | } 112 | 113 | /// Axum allows you to return `Result` from handler functions, but the error type 114 | /// also must be some sort of response type. 115 | /// 116 | /// By default, the generated `Display` impl is used to return a plaintext error message 117 | /// to the client. 118 | impl IntoResponse for Error { 119 | fn into_response(self) -> Response { 120 | match self { 121 | Self::UnprocessableEntity { errors } => { 122 | #[derive(serde::Serialize)] 123 | struct Errors { 124 | errors: HashMap, Vec>>, 125 | } 126 | 127 | return (StatusCode::UNPROCESSABLE_ENTITY, Json(Errors { errors })).into_response(); 128 | } 129 | Self::Unauthorized => { 130 | return ( 131 | self.status_code(), 132 | // Include the `WWW-Authenticate` challenge required in the specification 133 | // for the `401 Unauthorized` response code: 134 | // https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/401 135 | // 136 | // The Realworld spec does not specify this: 137 | // https://realworld-docs.netlify.app/docs/specs/backend-specs/error-handling 138 | // 139 | // However, at Launchbadge we try to adhere to web standards wherever possible, 140 | // if nothing else than to try to act as a vanguard of sanity on the web. 141 | [(WWW_AUTHENTICATE, HeaderValue::from_static("Token"))] 142 | .into_iter() 143 | .collect::(), 144 | self.to_string(), 145 | ) 146 | .into_response(); 147 | } 148 | 149 | Self::Sqlx(ref e) => { 150 | // TODO: we probably want to use `tracing` instead 151 | // so that this gets linked to the HTTP request by `TraceLayer`. 152 | tracing::error!("SQLx error: {:?}", e); 153 | } 154 | 155 | Self::Anyhow(ref e) => { 156 | // TODO: we probably want to use `tracing` instead 157 | // so that this gets linked to the HTTP request by `TraceLayer`. 158 | tracing::error!("Generic error: {:?}", e); 159 | } 160 | 161 | // Other errors get mapped normally. 162 | _ => (), 163 | } 164 | 165 | (self.status_code(), self.to_string()).into_response() 166 | } 167 | } 168 | 169 | /// A little helper trait for more easily converting database constraint errors into API errors. 170 | /// 171 | /// ```rust,ignore 172 | /// let user_id = sqlx::query_scalar!( 173 | /// r#"insert into "user" (username, email, password_hash) values ($1, $2, $3) returning user_id"#, 174 | /// username, 175 | /// email, 176 | /// password_hash 177 | /// ) 178 | /// .fetch_one(&ctxt.db) 179 | /// .await 180 | /// .on_constraint("user_username_key", |_| Error::unprocessable_entity([("username", "already taken")]))?; 181 | /// ``` 182 | /// 183 | /// Something like this would ideally live in a `sqlx-axum` crate if it made sense to author one, 184 | /// however its definition is tied pretty intimately to the `Error` type, which is itself 185 | /// tied directly to application semantics. 186 | /// 187 | /// To actually make this work in a generic context would make it quite a bit more complex, 188 | /// as you'd need an intermediate error type to represent either a mapped or an unmapped error, 189 | /// and even then it's not clear how to handle `?` in the unmapped case without more boilerplate. 190 | pub trait ResultExt { 191 | /// If `self` contains a SQLx database constraint error with the given name, 192 | /// transform the error. 193 | /// 194 | /// Otherwise, the result is passed through unchanged. 195 | fn on_constraint( 196 | self, 197 | name: &str, 198 | f: impl FnOnce(Box) -> Error, 199 | ) -> Result; 200 | } 201 | 202 | impl ResultExt for Result 203 | where 204 | E: Into, 205 | { 206 | fn on_constraint( 207 | self, 208 | name: &str, 209 | map_err: impl FnOnce(Box) -> Error, 210 | ) -> Result { 211 | self.map_err(|e| match e.into() { 212 | Error::Sqlx(sqlx::Error::Database(dbe)) if dbe.constraint() == Some(name) => { 213 | map_err(dbe) 214 | } 215 | e => e, 216 | }) 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /chuckle-http/src/util/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod error; 2 | -------------------------------------------------------------------------------- /chuckle-interactions/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chuckle-interactions" 3 | description = "Logic for handling Discord interactions." 4 | version = { workspace = true } 5 | license = { workspace = true } 6 | edition = { workspace = true } 7 | authors = { workspace = true } 8 | repository = { workspace = true } 9 | homepage = { workspace = true } 10 | 11 | [dependencies] 12 | anyhow = { workspace = true } 13 | chrono = { workspace = true } 14 | chuckle-util = { workspace = true } 15 | hex = { workspace = true } 16 | ms = "0.1.1" 17 | once_cell = { workspace = true } 18 | rand = "0.8.5" 19 | reqwest = { workspace = true } 20 | serde_json = { workspace = true } 21 | sqlx = { workspace = true } 22 | time = { workspace = true } 23 | tokio = { workspace = true, optional = true } 24 | tracing = { workspace = true } 25 | twilight-cache-inmemory = { workspace = true } 26 | twilight-model = { workspace = true } 27 | twilight-util = { workspace = true } 28 | uuid = { workspace = true } 29 | vesper = { workspace = true } 30 | 31 | [features] 32 | default = ["lockfile"] 33 | lockfile = ["tokio"] 34 | -------------------------------------------------------------------------------- /chuckle-interactions/commands.lock.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "default_member_permissions": null, 4 | "description": "Set a custom role color.", 5 | "name": "hexil", 6 | "options": [ 7 | { 8 | "autocomplete": false, 9 | "choices": [], 10 | "description": "A hex code to set your role color to.", 11 | "name": "hex", 12 | "required": true, 13 | "type": 3 14 | } 15 | ], 16 | "type": 1, 17 | "version": "1" 18 | }, 19 | { 20 | "default_member_permissions": null, 21 | "description": "Set a custom role color.", 22 | "name": "link-github", 23 | "options": [ 24 | { 25 | "autocomplete": false, 26 | "choices": [], 27 | "description": "Your GitHub username.", 28 | "name": "username", 29 | "required": true, 30 | "type": 3 31 | } 32 | ], 33 | "type": 1, 34 | "version": "1" 35 | }, 36 | { 37 | "default_member_permissions": null, 38 | "description": "Ping the bot.", 39 | "name": "ping", 40 | "options": [], 41 | "type": 1, 42 | "version": "1" 43 | }, 44 | { 45 | "default_member_permissions": null, 46 | "description": "", 47 | "name": "Circle Back", 48 | "options": [], 49 | "type": 3, 50 | "version": "1" 51 | }, 52 | { 53 | "default_member_permissions": null, 54 | "description": "Get the comments for a PR.", 55 | "name": "pr-comments", 56 | "options": [ 57 | { 58 | "autocomplete": false, 59 | "choices": [], 60 | "description": "The PR number to register for.", 61 | "max_value": 32767, 62 | "min_value": -32768, 63 | "name": "pr", 64 | "required": true, 65 | "type": 4 66 | }, 67 | { 68 | "autocomplete": false, 69 | "choices": [], 70 | "description": "The owner of the repo.", 71 | "name": "owner", 72 | "required": false, 73 | "type": 3 74 | }, 75 | { 76 | "autocomplete": false, 77 | "choices": [], 78 | "description": "The repo name.", 79 | "name": "repo", 80 | "required": false, 81 | "type": 3 82 | } 83 | ], 84 | "type": 1, 85 | "version": "1" 86 | }, 87 | { 88 | "default_member_permissions": null, 89 | "description": "Configure the bot for your server.", 90 | "name": "config", 91 | "options": [ 92 | { 93 | "description": "Configure the breakout category.", 94 | "name": "breakout-category", 95 | "options": [ 96 | { 97 | "description": "Set the Breakout Rooms category", 98 | "name": "set", 99 | "options": [ 100 | { 101 | "channel_types": [], 102 | "description": "The current Breakout Rooms category", 103 | "name": "category", 104 | "required": true, 105 | "type": 7 106 | } 107 | ], 108 | "type": 1 109 | }, 110 | { 111 | "description": "List the current Breakout Rooms category", 112 | "name": "list", 113 | "options": [], 114 | "type": 1 115 | }, 116 | { 117 | "description": "Unset the current Breakout Rooms category", 118 | "name": "unset", 119 | "options": [], 120 | "type": 1 121 | } 122 | ], 123 | "type": 2 124 | }, 125 | { 126 | "description": "Configure the default GitHub repository for PR Comments.", 127 | "name": "default-repo", 128 | "options": [ 129 | { 130 | "description": "List the default repository for PR Comments", 131 | "name": "list", 132 | "options": [], 133 | "type": 1 134 | }, 135 | { 136 | "description": "Set the default repository for PR Comments", 137 | "name": "set", 138 | "options": [ 139 | { 140 | "autocomplete": false, 141 | "choices": [], 142 | "description": "The default repository for PR Comments", 143 | "name": "repository", 144 | "required": true, 145 | "type": 3 146 | } 147 | ], 148 | "type": 1 149 | }, 150 | { 151 | "description": "Unset the default GitHub repository for PR Comments", 152 | "name": "unset", 153 | "options": [], 154 | "type": 1 155 | } 156 | ], 157 | "type": 2 158 | }, 159 | { 160 | "description": "List the configuration.", 161 | "name": "display", 162 | "options": [ 163 | { 164 | "description": "List the configuration.", 165 | "name": "do", 166 | "options": [], 167 | "type": 1 168 | } 169 | ], 170 | "type": 2 171 | }, 172 | { 173 | "description": "Configure the forum log channel.", 174 | "name": "forum-log", 175 | "options": [ 176 | { 177 | "description": "Set the forum log channel.", 178 | "name": "set", 179 | "options": [ 180 | { 181 | "channel_types": [], 182 | "description": "The channel to use as the forum log.", 183 | "name": "channel", 184 | "required": true, 185 | "type": 7 186 | } 187 | ], 188 | "type": 1 189 | }, 190 | { 191 | "description": "List the forum log channel.", 192 | "name": "list", 193 | "options": [], 194 | "type": 1 195 | }, 196 | { 197 | "description": "Unset the forum log channel.", 198 | "name": "unset", 199 | "options": [], 200 | "type": 1 201 | } 202 | ], 203 | "type": 2 204 | }, 205 | { 206 | "description": "Configure the default GitHub organization for PR Comments.", 207 | "name": "default-org", 208 | "options": [ 209 | { 210 | "description": "Unset the default GitHub organization for PR Comments", 211 | "name": "unset", 212 | "options": [], 213 | "type": 1 214 | }, 215 | { 216 | "description": "List the default organization for PR Comments", 217 | "name": "list", 218 | "options": [], 219 | "type": 1 220 | }, 221 | { 222 | "description": "Set the default organization for PR Comments", 223 | "name": "set", 224 | "options": [ 225 | { 226 | "autocomplete": false, 227 | "choices": [], 228 | "description": "The default organization for PR Comments", 229 | "name": "organization", 230 | "required": true, 231 | "type": 3 232 | } 233 | ], 234 | "type": 1 235 | } 236 | ], 237 | "type": 2 238 | } 239 | ], 240 | "type": 1, 241 | "version": "1" 242 | }, 243 | { 244 | "default_member_permissions": null, 245 | "description": "Commands for managing breakout rooms.", 246 | "name": "breakout-rooms", 247 | "options": [ 248 | { 249 | "description": "Separate the people in a voice channel into breakout rooms.", 250 | "name": "create", 251 | "options": [ 252 | { 253 | "channel_types": [], 254 | "description": "Which voice channel to select people from", 255 | "name": "channel", 256 | "required": true, 257 | "type": 7 258 | }, 259 | { 260 | "autocomplete": false, 261 | "choices": [], 262 | "description": "How many people per room", 263 | "max_value": 255, 264 | "min_value": 0, 265 | "name": "size", 266 | "required": true, 267 | "type": 4 268 | }, 269 | { 270 | "autocomplete": false, 271 | "choices": [ 272 | { 273 | "name": "Overflow", 274 | "value": 1 275 | }, 276 | { 277 | "name": "Exclude", 278 | "value": 2 279 | } 280 | ], 281 | "description": "What to do with people who don't fit into a room", 282 | "name": "remainder_strategy", 283 | "required": true, 284 | "type": 4 285 | } 286 | ], 287 | "type": 1 288 | }, 289 | { 290 | "description": "Close breakout rooms and bring everyone back.", 291 | "name": "destroy", 292 | "options": [ 293 | { 294 | "channel_types": [], 295 | "description": "Where to bring everyone back to", 296 | "name": "channel", 297 | "required": true, 298 | "type": 7 299 | } 300 | ], 301 | "type": 1 302 | } 303 | ], 304 | "type": 1, 305 | "version": "1" 306 | }, 307 | { 308 | "default_member_permissions": null, 309 | "description": "Commands for managing threads.", 310 | "name": "threads", 311 | "options": [ 312 | { 313 | "description": "Add all people from a provided role to this thread.", 314 | "name": "add_role", 315 | "options": [ 316 | { 317 | "description": "Which role to add from", 318 | "name": "role", 319 | "required": true, 320 | "type": 8 321 | } 322 | ], 323 | "type": 1 324 | } 325 | ], 326 | "type": 1, 327 | "version": "1" 328 | } 329 | ] 330 | -------------------------------------------------------------------------------- /chuckle-interactions/deploy-commands.sh: -------------------------------------------------------------------------------- 1 | curl -X PUT https://discord.com/api/v10/applications/$DISCORD_APPLICATION_ID/commands \ 2 | -H "Authorization: Bot $DISCORD_TOKEN" \ 3 | -H "content-type: application/json" \ 4 | -d @./commands.lock.json 5 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/breakout_rooms.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use chuckle_util::{ 4 | chunkify::{chunkify, RemainderStrategy}, 5 | db::get_settings, 6 | ChuckleState, 7 | }; 8 | use rand::seq::SliceRandom; 9 | use twilight_model::{channel::Channel, id::Id}; 10 | use vesper::{prelude::*, twilight_exports::ChannelMarker}; 11 | 12 | use super::{edit_response, handle_generic_error, text_response}; 13 | 14 | #[tracing::instrument(skip(ctx))] 15 | #[command] 16 | #[description = "Separate the people in a voice channel into breakout rooms."] 17 | #[only_guilds] 18 | #[error_handler(handle_generic_error)] 19 | pub async fn create( 20 | ctx: &SlashContext, 21 | #[description = "Which voice channel to select people from"] channel: Id, 22 | #[description = "How many people per room"] size: u8, 23 | #[description = "What to do with people who don't fit into a room"] 24 | remainder_strategy: RemainderStrategy, 25 | ) -> DefaultCommandResult { 26 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 27 | if settings.breakout_rooms_category_id.is_none() { 28 | return text_response(ctx, "No breakout rooms category set.".to_string(), true).await; 29 | } 30 | 31 | let mut voice_states = ctx 32 | .data 33 | .cache 34 | .voice_channel_states(channel) 35 | .map_or(Vec::new(), |states| { 36 | states.into_iter().map(|s| s.user_id()).collect() 37 | }); 38 | if voice_states.len() < size.into() { 39 | return text_response( 40 | ctx, 41 | format!( 42 | "There are fewer people in <#{}> than the size of your breakout rooms.", 43 | channel 44 | ), 45 | true, 46 | ) 47 | .await; 48 | } 49 | voice_states.shuffle(&mut rand::thread_rng()); 50 | 51 | let rooms = chunkify(voice_states, size.into(), remainder_strategy).chunks; 52 | 53 | // for every room, create a new voice channel under the breakout rooms category 54 | // then, move the people in that room into the new voice channel 55 | for (i, members) in rooms.iter().enumerate() { 56 | let room_name = format!("Breakout Room {i}"); 57 | 58 | let room = ctx 59 | .http_client() 60 | .create_guild_channel(ctx.interaction.guild_id.unwrap(), &room_name)? 61 | .kind(twilight_model::channel::ChannelType::GuildVoice) 62 | .parent_id( 63 | Id::::from_str( 64 | &settings.breakout_rooms_category_id.clone().unwrap(), 65 | ) 66 | .unwrap(), 67 | ) 68 | .await? 69 | .model() 70 | .await?; 71 | 72 | for member in members { 73 | ctx.http_client() 74 | .update_guild_member(ctx.interaction.guild_id.unwrap(), *member) 75 | .channel_id(Some(room.id)) 76 | .await?; 77 | } 78 | } 79 | 80 | text_response(ctx, "Pong!".to_string(), true).await 81 | } 82 | 83 | #[tracing::instrument(skip(ctx))] 84 | #[command] 85 | #[description = "Close breakout rooms and bring everyone back."] 86 | #[only_guilds] 87 | #[error_handler(handle_generic_error)] 88 | pub async fn destroy( 89 | ctx: &SlashContext, 90 | #[description = "Where to bring everyone back to"] channel: Id, 91 | ) -> DefaultCommandResult { 92 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 93 | if settings.breakout_rooms_category_id.is_none() { 94 | return text_response(ctx, "No breakout rooms category set.".to_string(), true).await; 95 | } 96 | ctx.defer(false).await?; 97 | let breakout_rooms_category_id = settings.breakout_rooms_category_id.unwrap(); 98 | 99 | let voice_states = ctx 100 | .data 101 | .cache 102 | .guild_voice_states(ctx.interaction.guild_id.unwrap()) 103 | .map_or(Vec::new(), |states| { 104 | states 105 | .value() 106 | .iter() 107 | .map(|s| { 108 | ctx.data 109 | .cache 110 | .voice_state(*s, ctx.interaction.guild_id.unwrap()) 111 | .unwrap() 112 | }) 113 | .collect() 114 | }); 115 | tracing::debug!("found {} voice states", voice_states.len()); 116 | tracing::debug!("{:#?}", voice_states); 117 | 118 | let mut voice_channels: Vec = Vec::new(); 119 | for state in voice_states { 120 | tracing::debug!("checking state {}", state.session_id()); 121 | let channel_id = state.channel_id(); 122 | let voice_channel = ctx.data.cache.channel(channel_id).unwrap(); 123 | if voice_channel.parent_id.is_none() { 124 | tracing::debug!("channel {:?} has no parent", voice_channel.name); 125 | continue; 126 | } 127 | 128 | if voice_channel.parent_id.unwrap().to_string() != breakout_rooms_category_id { 129 | tracing::debug!( 130 | "channel {:?} is not in breakout rooms category", 131 | voice_channel.name 132 | ); 133 | continue; 134 | } 135 | tracing::debug!("found channel: {:?}", voice_channel.name); 136 | 137 | if !voice_channels.contains(&voice_channel) { 138 | voice_channels.push(voice_channel.to_owned()); 139 | } 140 | 141 | // move the member back to home 142 | tracing::info!("moving user: {:?}", state.user_id()); 143 | let _ = tokio::spawn(async move { 144 | let res = ctx 145 | .http_client() 146 | .update_guild_member(ctx.interaction.guild_id.unwrap(), state.user_id()) 147 | .channel_id(Some(channel)) 148 | .await; 149 | 150 | tracing::info!("successfully moved user: {:?}", res); 151 | }); 152 | } 153 | 154 | // delete the voice channels 155 | for channel in voice_channels { 156 | ctx.http_client().delete_channel(channel.id).await?; 157 | } 158 | 159 | edit_response(ctx, "Pong!".to_string()).await 160 | } 161 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/config/breakout_category.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use twilight_model::id::{marker::ChannelMarker, Id}; 3 | use vesper::prelude::*; 4 | 5 | use crate::commands::{handle_generic_error, text_response}; 6 | 7 | #[tracing::instrument(skip(ctx))] 8 | #[command] 9 | #[description = "List the current Breakout Rooms category"] 10 | #[only_guilds] 11 | #[error_handler(handle_generic_error)] 12 | pub async fn list(ctx: &SlashContext) -> DefaultCommandResult { 13 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 14 | 15 | if settings.breakout_rooms_category_id.is_none() { 16 | text_response( 17 | ctx, 18 | "There is no current Breakout Rooms category.".to_string(), 19 | true, 20 | ) 21 | .await 22 | } else { 23 | text_response( 24 | ctx, 25 | format!( 26 | "The current default current Breakout Rooms category is <#{}>.", 27 | settings.breakout_rooms_category_id.unwrap() 28 | ), 29 | true, 30 | ) 31 | .await 32 | } 33 | } 34 | 35 | #[tracing::instrument(skip(ctx))] 36 | #[command] 37 | #[description = "Set the Breakout Rooms category"] 38 | #[only_guilds] 39 | #[required_permissions(MANAGE_GUILD)] 40 | #[error_handler(handle_generic_error)] 41 | pub async fn set( 42 | ctx: &SlashContext, 43 | #[description = "The current Breakout Rooms category"] category: Id, 44 | ) -> DefaultCommandResult { 45 | let res = sqlx::query!( 46 | "update guild_settings set breakout_rooms_category_id = $1 where guild_id = $2", 47 | category.to_string(), 48 | ctx.interaction.guild_id.unwrap().to_string() 49 | ) 50 | .execute(&ctx.data.db) 51 | .await; 52 | 53 | let (content, ephemeral) = match res { 54 | Ok(_) => ( 55 | format!("Successfully set the current Breakout Rooms category to <#{category}>.",), 56 | false, 57 | ), 58 | Err(_) => ( 59 | "Failed to set the current Breakout Rooms category.".to_string(), 60 | true, 61 | ), 62 | }; 63 | 64 | text_response(ctx, content, ephemeral).await 65 | } 66 | 67 | #[tracing::instrument(skip(ctx))] 68 | #[command] 69 | #[description = "Unset the current Breakout Rooms category"] 70 | #[only_guilds] 71 | #[error_handler(handle_generic_error)] 72 | pub async fn unset(ctx: &SlashContext) -> DefaultCommandResult { 73 | let res = sqlx::query!( 74 | "update guild_settings set breakout_rooms_category_id = null where guild_id = $1", 75 | ctx.interaction.guild_id.unwrap().to_string() 76 | ) 77 | .execute(&ctx.data.db) 78 | .await; 79 | 80 | let (content, ephemeral) = match res { 81 | Ok(_) => ( 82 | "Successfully unset the current Breakout Rooms category.".to_string(), 83 | false, 84 | ), 85 | Err(_) => ( 86 | "Failed to unset the current Breakout Rooms category.".to_string(), 87 | true, 88 | ), 89 | }; 90 | 91 | text_response(ctx, content, ephemeral).await 92 | } 93 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/config/default_org.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use vesper::prelude::*; 3 | 4 | use crate::commands::{handle_generic_error, text_response}; 5 | 6 | #[tracing::instrument(skip(ctx))] 7 | #[command] 8 | #[description = "List the default organization for PR Comments"] 9 | #[only_guilds] 10 | #[error_handler(handle_generic_error)] 11 | pub async fn list(ctx: &SlashContext) -> DefaultCommandResult { 12 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 13 | 14 | if settings.default_repository_owner.is_none() { 15 | text_response( 16 | ctx, 17 | "There is no default GitHub organization.".to_string(), 18 | true, 19 | ) 20 | .await 21 | } else { 22 | text_response( 23 | ctx, 24 | format!( 25 | "The current default GitHub organization is `{}`", 26 | settings.default_repository_owner.unwrap() 27 | ), 28 | true, 29 | ) 30 | .await 31 | } 32 | } 33 | 34 | #[tracing::instrument(skip(ctx))] 35 | #[command] 36 | #[description = "Set the default organization for PR Comments"] 37 | #[only_guilds] 38 | #[required_permissions(MANAGE_GUILD)] 39 | #[error_handler(handle_generic_error)] 40 | pub async fn set( 41 | ctx: &SlashContext, 42 | #[description = "The default organization for PR Comments"] organization: String, 43 | ) -> DefaultCommandResult { 44 | let res = sqlx::query!( 45 | "update guild_settings set default_repository_owner = $1 where guild_id = $2", 46 | organization.to_string(), 47 | ctx.interaction.guild_id.unwrap().to_string() 48 | ) 49 | .execute(&ctx.data.db) 50 | .await; 51 | 52 | let (content, ephemeral) = match res { 53 | Ok(_) => ( 54 | format!( 55 | "Successfully set the default GitHub organization to `{}`.", 56 | organization 57 | ), 58 | false, 59 | ), 60 | Err(_) => ( 61 | "Failed to set the default GitHub organization.".to_string(), 62 | true, 63 | ), 64 | }; 65 | 66 | text_response(ctx, content, ephemeral).await 67 | } 68 | 69 | #[tracing::instrument(skip(ctx))] 70 | #[command] 71 | #[description = "Unset the default GitHub organization for PR Comments"] 72 | #[only_guilds] 73 | #[error_handler(handle_generic_error)] 74 | pub async fn unset(ctx: &SlashContext) -> DefaultCommandResult { 75 | let res = sqlx::query!( 76 | "update guild_settings set default_repository_owner = null where guild_id = $1", 77 | ctx.interaction.guild_id.unwrap().to_string() 78 | ) 79 | .execute(&ctx.data.db) 80 | .await; 81 | 82 | let (content, ephemeral) = match res { 83 | Ok(_) => ( 84 | "Successfully unset the default GitHub organization.".to_string(), 85 | false, 86 | ), 87 | Err(_) => ( 88 | "Failed to unset the default GitHub organization.".to_string(), 89 | true, 90 | ), 91 | }; 92 | 93 | text_response(ctx, content, ephemeral).await 94 | } 95 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/config/default_repo.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use vesper::prelude::*; 3 | 4 | use crate::commands::{handle_generic_error, text_response}; 5 | 6 | #[tracing::instrument(skip(ctx))] 7 | #[command] 8 | #[description = "List the default repository for PR Comments"] 9 | #[only_guilds] 10 | #[error_handler(handle_generic_error)] 11 | pub async fn list(ctx: &SlashContext) -> DefaultCommandResult { 12 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 13 | 14 | if settings.default_repository.is_none() { 15 | text_response( 16 | ctx, 17 | "There is no default GitHub repository.".to_string(), 18 | true, 19 | ) 20 | .await 21 | } else { 22 | text_response( 23 | ctx, 24 | format!( 25 | "The current default GitHub repository is `{}`", 26 | settings.default_repository.unwrap() 27 | ), 28 | true, 29 | ) 30 | .await 31 | } 32 | } 33 | 34 | #[tracing::instrument(skip(ctx))] 35 | #[command] 36 | #[description = "Set the default repository for PR Comments"] 37 | #[only_guilds] 38 | #[required_permissions(MANAGE_GUILD)] 39 | #[error_handler(handle_generic_error)] 40 | pub async fn set( 41 | ctx: &SlashContext, 42 | #[description = "The default repository for PR Comments"] repository: String, 43 | ) -> DefaultCommandResult { 44 | let res = sqlx::query!( 45 | "update guild_settings set default_repository = $1 where guild_id = $2", 46 | repository.to_string(), 47 | ctx.interaction.guild_id.unwrap().to_string() 48 | ) 49 | .execute(&ctx.data.db) 50 | .await; 51 | 52 | let (content, ephemeral) = match res { 53 | Ok(_) => ( 54 | format!( 55 | "Successfully set the default GitHub repository to `{}`.", 56 | repository 57 | ), 58 | false, 59 | ), 60 | Err(_) => ( 61 | "Failed to set the default GitHub repository.".to_string(), 62 | true, 63 | ), 64 | }; 65 | 66 | text_response(ctx, content, ephemeral).await 67 | } 68 | 69 | #[tracing::instrument(skip(ctx))] 70 | #[command] 71 | #[description = "Unset the default GitHub repository for PR Comments"] 72 | #[only_guilds] 73 | #[error_handler(handle_generic_error)] 74 | pub async fn unset(ctx: &SlashContext) -> DefaultCommandResult { 75 | let res = sqlx::query!( 76 | "update guild_settings set default_repository = null where guild_id = $1", 77 | ctx.interaction.guild_id.unwrap().to_string() 78 | ) 79 | .execute(&ctx.data.db) 80 | .await; 81 | 82 | let (content, ephemeral) = match res { 83 | Ok(_) => ( 84 | "Successfully unset the default GitHub repository.".to_string(), 85 | false, 86 | ), 87 | Err(_) => ( 88 | "Failed to unset the default GitHub repository.".to_string(), 89 | true, 90 | ), 91 | }; 92 | 93 | text_response(ctx, content, ephemeral).await 94 | } 95 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/config/forum_log.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use twilight_model::id::{marker::ChannelMarker, Id}; 3 | use vesper::prelude::*; 4 | 5 | use crate::commands::{handle_generic_error, text_response}; 6 | 7 | #[tracing::instrument(skip(ctx))] 8 | #[command] 9 | #[description = "List the forum log channel."] 10 | #[only_guilds] 11 | #[error_handler(handle_generic_error)] 12 | pub async fn list(ctx: &SlashContext) -> DefaultCommandResult { 13 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 14 | 15 | if settings.forum_log_channel_id.is_none() { 16 | text_response(ctx, "No forum log channel set.".to_string(), true).await 17 | } else { 18 | text_response( 19 | ctx, 20 | format!( 21 | "The current forum log channel is <#{}>", 22 | settings.forum_log_channel_id.unwrap() 23 | ), 24 | true, 25 | ) 26 | .await 27 | } 28 | } 29 | 30 | #[tracing::instrument(skip(ctx))] 31 | #[command] 32 | #[description = "Set the forum log channel."] 33 | #[only_guilds] 34 | #[required_permissions(MANAGE_GUILD)] 35 | #[error_handler(handle_generic_error)] 36 | pub async fn set( 37 | ctx: &SlashContext, 38 | #[description = "The channel to use as the forum log."] channel: Id, 39 | ) -> DefaultCommandResult { 40 | let res = sqlx::query!( 41 | "update guild_settings set forum_log_channel_id = $1 where guild_id = $2", 42 | channel.to_string(), 43 | ctx.interaction.guild_id.unwrap().to_string() 44 | ) 45 | .execute(&ctx.data.db) 46 | .await; 47 | 48 | let (content, ephemeral) = match res { 49 | Ok(_) => ( 50 | format!("Successfully set the forum log channel to <#{}>", channel), 51 | false, 52 | ), 53 | Err(_) => ("Failed to set the forum log channel.".to_string(), true), 54 | }; 55 | 56 | text_response(ctx, content, ephemeral).await 57 | } 58 | 59 | #[tracing::instrument(skip(ctx))] 60 | #[command] 61 | #[description = "Unset the forum log channel."] 62 | #[only_guilds] 63 | #[error_handler(handle_generic_error)] 64 | pub async fn unset(ctx: &SlashContext) -> DefaultCommandResult { 65 | let res = sqlx::query!( 66 | "update guild_settings set forum_log_channel_id = null where guild_id = $1", 67 | ctx.interaction.guild_id.unwrap().to_string() 68 | ) 69 | .execute(&ctx.data.db) 70 | .await; 71 | 72 | let (content, ephemeral) = match res { 73 | Ok(_) => ( 74 | "Successfully unset the forum log channel.".to_string(), 75 | false, 76 | ), 77 | Err(_) => ("Failed to unset the forum log channel.".to_string(), true), 78 | }; 79 | 80 | text_response(ctx, content, ephemeral).await 81 | } 82 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/config/mod.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use vesper::prelude::*; 3 | 4 | use super::{handle_generic_error, text_response}; 5 | 6 | pub mod breakout_category; 7 | pub mod default_org; 8 | pub mod default_repo; 9 | pub mod forum_log; 10 | 11 | #[tracing::instrument(skip(ctx))] 12 | #[command("do")] 13 | #[description = "List the configuration."] 14 | #[only_guilds] 15 | #[error_handler(handle_generic_error)] 16 | pub async fn list(ctx: &SlashContext) -> DefaultCommandResult { 17 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 18 | let guild = ctx 19 | .http_client() 20 | .guild(ctx.interaction.guild_id.unwrap()) 21 | .await? 22 | .model() 23 | .await?; 24 | 25 | let content = format!( 26 | r#" 27 | **Configuration for {}** 28 | 29 | Breakout category: <#{}> 30 | Forum log channel: <#{}> 31 | Default organization: `{}` 32 | Default repository: `{}` 33 | "#, 34 | guild.name, 35 | settings 36 | .breakout_rooms_category_id 37 | .unwrap_or("None".to_string()), 38 | settings.forum_log_channel_id.unwrap_or("None".to_string()), 39 | settings 40 | .default_repository_owner 41 | .unwrap_or("None".to_string()), 42 | settings.default_repository.unwrap_or("None".to_string()), 43 | ); 44 | 45 | text_response(ctx, content, false).await 46 | } 47 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/hexil.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::ChuckleState; 2 | use std::str::FromStr; 3 | use twilight_model::{ 4 | guild::Permissions, 5 | id::{marker::RoleMarker, Id}, 6 | }; 7 | use vesper::prelude::*; 8 | 9 | use super::{handle_generic_error, text_response, user_from_interaction}; 10 | 11 | #[tracing::instrument(skip(ctx))] 12 | #[command] 13 | #[description = "Set a custom role color."] 14 | #[only_guilds] 15 | #[error_handler(handle_generic_error)] 16 | pub async fn hexil( 17 | ctx: &SlashContext, 18 | #[rename = "hex"] 19 | #[description = "A hex code to set your role color to."] 20 | hex_input: String, 21 | ) -> DefaultCommandResult { 22 | let user = user_from_interaction(&ctx.interaction); 23 | let hex_input = hex_input.replace('#', ""); 24 | 25 | // check if the hex code is valid color 26 | let hex = match hex::decode(hex_input.clone()) { 27 | Ok(h) => h, 28 | Err(_) => { 29 | return text_response( 30 | ctx, 31 | format!("`#{}` is not a valid hex code.", hex_input,), 32 | false, 33 | ) 34 | .await; 35 | } 36 | }; 37 | let hex_int = hex.iter().fold(0, |acc, &x| (acc << 8) + x as u32); 38 | let guild_id = ctx.interaction.guild_id.unwrap(); 39 | 40 | let existing = sqlx::query!( 41 | r#"select * from hexil where guild_id = $1 and user_id = $2"#, 42 | guild_id.to_string(), 43 | user.id.to_string() 44 | ) 45 | .fetch_optional(&ctx.data.db) 46 | .await?; 47 | 48 | if existing.is_some() { 49 | let role_id = Id::::from_str(&existing.unwrap().role_id).unwrap(); 50 | // change the color of the role 51 | ctx.http_client() 52 | .update_role(guild_id, role_id) 53 | .color(Some(hex_int)) 54 | .name(Some(&user.name)) 55 | .await? 56 | .model() 57 | .await?; 58 | } else { 59 | // create role then create database entry 60 | let role = ctx 61 | .http_client() 62 | .create_role(guild_id) 63 | .color(hex_int) 64 | .name(&user.name) 65 | .permissions(Permissions::from_bits_truncate(0)) 66 | .await? 67 | .model() 68 | .await?; 69 | 70 | let _ = sqlx::query!( 71 | r#"insert into "hexil" (guild_id, user_id, role_id) values ($1, $2, $3)"#, 72 | guild_id.to_string(), 73 | user.id.to_string(), 74 | role.id.to_string() 75 | ) 76 | .execute(&ctx.data.db) 77 | .await?; 78 | 79 | // add role to user 80 | ctx.http_client() 81 | .add_guild_member_role(guild_id, user.id, role.id) 82 | .await?; 83 | } 84 | 85 | text_response( 86 | ctx, 87 | format!("Successfully set your role color to `#{}`.", hex_input), 88 | true, 89 | ) 90 | .await 91 | } 92 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/link_github.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::ChuckleState; 2 | use vesper::prelude::*; 3 | 4 | use super::{handle_generic_error, text_response, user_from_interaction}; 5 | 6 | #[tracing::instrument] 7 | async fn fetch_user_id(username: &String) -> anyhow::Result { 8 | let url = format!("https://api.github.com/users/{}", username); 9 | let resp = reqwest::Client::new() 10 | .get(url) 11 | .header("User-Agent", "chuckle-bot (github.com/trufflehq/chuckle)") 12 | .send() 13 | .await?; 14 | let user: serde_json::Value = resp.json().await?; 15 | 16 | let user_id = user.get("id").unwrap().as_i64().unwrap() as i32; 17 | 18 | Ok(user_id) 19 | } 20 | 21 | #[tracing::instrument(skip(ctx))] 22 | #[command("link-github")] 23 | #[description = "Set a custom role color."] 24 | #[only_guilds] 25 | #[error_handler(handle_generic_error)] 26 | pub async fn link_github( 27 | ctx: &SlashContext, 28 | #[description = "Your GitHub username."] username: String, 29 | ) -> DefaultCommandResult { 30 | let user = user_from_interaction(&ctx.interaction); 31 | 32 | let github_user = match fetch_user_id(&username).await { 33 | Ok(n) => n, 34 | Err(err) => { 35 | tracing::error!("Error fetching GitHub user: {:?}", err); 36 | 37 | return text_response( 38 | ctx, 39 | format!( 40 | r#" 41 | Couldn't find the GitHub user `{}`. 42 | ``` 43 | {:#?} 44 | ``` 45 | "#, 46 | username, err 47 | ), 48 | true, 49 | ) 50 | .await; 51 | } 52 | }; 53 | 54 | let existing_entry = sqlx::query!( 55 | r#"SELECT * FROM "user" WHERE discord_id = $1"#, 56 | user.id.to_string() 57 | ) 58 | .fetch_optional(&ctx.data.db) 59 | .await?; 60 | 61 | if existing_entry.is_some() { 62 | let _ = sqlx::query!( 63 | r#"update "user" set github_id = $1 where id = $2 returning id"#, 64 | github_user, 65 | existing_entry.unwrap().id, 66 | ) 67 | .fetch_one(&ctx.data.db) 68 | .await?; 69 | } else { 70 | let _ = sqlx::query!( 71 | r#"insert into "user" (discord_id, github_id) values ($1, $2)"#, 72 | user.id.to_string(), 73 | github_user, 74 | ) 75 | .fetch_one(&ctx.data.db) 76 | .await?; 77 | } 78 | 79 | text_response( 80 | ctx, 81 | format!( 82 | "Successfully registered your GitHub username as `{}` (`{}`).", 83 | username, github_user 84 | ), 85 | false, 86 | ) 87 | .await 88 | } 89 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::ChuckleState; 2 | use twilight_model::{ 3 | application::interaction::Interaction, 4 | channel::message::MessageFlags, 5 | http::interaction::{InteractionResponse, InteractionResponseData, InteractionResponseType}, 6 | user::User, 7 | }; 8 | use vesper::{framework::DefaultError, prelude::*}; 9 | 10 | // groups 11 | pub mod breakout_rooms; 12 | pub mod config; 13 | pub mod threads; 14 | 15 | mod hexil; 16 | mod link_github; 17 | mod ping; 18 | mod pr_comments; 19 | 20 | pub use {hexil::hexil, link_github::link_github, ping::ping, pr_comments::pr_comments}; 21 | 22 | pub fn user_from_interaction(interaction: &Interaction) -> User { 23 | if interaction.guild_id.is_some() { 24 | return interaction.member.clone().unwrap().user.unwrap(); 25 | } 26 | 27 | interaction.user.clone().unwrap() 28 | } 29 | 30 | #[error_handler] 31 | async fn handle_generic_error(ctx: &SlashContext, err: DefaultError) { 32 | tracing::error!("Error handling command: {:#?}", err); 33 | let _ = text_response( 34 | ctx, 35 | format!( 36 | r#" 37 | An unknown error occurred: 38 | ```rs 39 | {:#?} 40 | ``` 41 | "#, 42 | err 43 | ), 44 | true, 45 | ) 46 | .await; 47 | } 48 | 49 | /// Shorthand for editing the response to an interaction after being deferred. 50 | pub async fn edit_response( 51 | ctx: &SlashContext<'_, ChuckleState>, 52 | text: String, 53 | ) -> DefaultCommandResult { 54 | ctx.interaction_client 55 | .update_response(&ctx.interaction.token) 56 | .content(Some(&text)) 57 | .unwrap() 58 | .await?; 59 | 60 | Ok(()) 61 | } 62 | 63 | /// Shorthand to creating a text response to an interaction. 64 | pub async fn text_response( 65 | ctx: &SlashContext<'_, ChuckleState>, 66 | text: String, 67 | ephemeral: bool, 68 | ) -> DefaultCommandResult { 69 | ctx.interaction_client 70 | .create_response( 71 | ctx.interaction.id, 72 | &ctx.interaction.token, 73 | &InteractionResponse { 74 | kind: InteractionResponseType::ChannelMessageWithSource, 75 | data: Some(InteractionResponseData { 76 | content: Some(text), 77 | flags: if ephemeral { 78 | Some(MessageFlags::EPHEMERAL) 79 | } else { 80 | None 81 | }, 82 | ..Default::default() 83 | }), 84 | }, 85 | ) 86 | .await?; 87 | 88 | Ok(()) 89 | } 90 | 91 | /// Shorthand to creating a text response to an interaction. 92 | pub async fn create_followup( 93 | ctx: &SlashContext<'_, ChuckleState>, 94 | text: String, 95 | ephemeral: bool, 96 | ) -> DefaultCommandResult { 97 | let mut builder = ctx 98 | .interaction_client 99 | .create_followup(&ctx.interaction.token) 100 | .content(&text)?; 101 | 102 | if ephemeral { 103 | builder = builder.flags(MessageFlags::EPHEMERAL); 104 | } 105 | 106 | builder.await?; 107 | 108 | Ok(()) 109 | } 110 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/ping.rs: -------------------------------------------------------------------------------- 1 | use super::{handle_generic_error, text_response}; 2 | use chuckle_util::ChuckleState; 3 | use vesper::prelude::*; 4 | 5 | #[tracing::instrument(skip(ctx))] 6 | #[command] 7 | #[description = "Ping the bot."] 8 | #[error_handler(handle_generic_error)] 9 | pub async fn ping(ctx: &SlashContext) -> DefaultCommandResult { 10 | text_response(ctx, "Pong!".to_string(), true).await 11 | } 12 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/pr_comments.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use vesper::prelude::*; 3 | 4 | use super::{handle_generic_error, text_response}; 5 | 6 | #[tracing::instrument(skip(ctx))] 7 | #[command("pr-comments")] 8 | #[description = "Get the comments for a PR."] 9 | #[only_guilds] 10 | #[error_handler(handle_generic_error)] 11 | pub async fn pr_comments( 12 | ctx: &SlashContext, 13 | #[description = "The PR number to register for."] pr: i16, 14 | #[description = "The owner of the repo."] owner: Option, 15 | #[description = "The repo name."] repo: Option, 16 | ) -> DefaultCommandResult { 17 | let pr = pr as i32; 18 | let settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 19 | 20 | let owner = owner.unwrap_or(settings.default_repository_owner.unwrap_or("".to_string())); 21 | let repo = repo.unwrap_or(settings.default_repository.unwrap_or("".to_string())); 22 | tracing::info!("Received pr-comments command: {}/{}/{}", owner, repo, pr); 23 | 24 | if owner.is_empty() || repo.is_empty() { 25 | return text_response( 26 | ctx, 27 | "Please set a default repository and owner with `/config default-repo set` and `/config default-org`.".to_string(), 28 | true, 29 | ) 30 | .await; 31 | } 32 | 33 | let thread_id = ctx.interaction.channel.clone().unwrap().id.to_string(); 34 | 35 | let existing_entry = sqlx::query!( 36 | "SELECT * FROM pr_review_output WHERE pr_number = $1 AND repo_owner = $2 AND repo = $3", 37 | pr, 38 | owner, 39 | repo 40 | ) 41 | .fetch_optional(&ctx.data.db) 42 | .await?; 43 | 44 | if existing_entry.is_some() { 45 | let revised_entry = sqlx::query!( 46 | "update pr_review_output set thread_id = $1 where id = $2 returning id", 47 | thread_id, 48 | existing_entry.unwrap().id, 49 | ) 50 | .fetch_one(&ctx.data.db) 51 | .await?; 52 | 53 | return text_response( 54 | ctx, 55 | format!( 56 | "Revised the review output for `{}/{}#{}` to post in <#{}> (`{}`).", 57 | owner, repo, pr, thread_id, revised_entry.id 58 | ), 59 | false, 60 | ) 61 | .await; 62 | } 63 | 64 | let entry = sqlx::query!( 65 | "insert into pr_review_output (pr_number, repo_owner, repo, thread_id) values ($1, $2, $3, $4) returning id", 66 | pr, 67 | owner, 68 | repo, 69 | thread_id, 70 | ).fetch_one(&ctx.data.db).await?; 71 | 72 | text_response( 73 | ctx, 74 | format!( 75 | "Review output for `{}/{}#{}` to post in <#{}> (`{}`).", 76 | owner, repo, pr, thread_id, entry.id 77 | ), 78 | false, 79 | ) 80 | .await 81 | } 82 | -------------------------------------------------------------------------------- /chuckle-interactions/src/commands/threads.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::{db::get_settings, ChuckleState}; 2 | use twilight_model::http::interaction::{ 3 | InteractionResponse, InteractionResponseData, InteractionResponseType, 4 | }; 5 | use twilight_model::{channel::message::AllowedMentions, id::Id}; 6 | use vesper::{prelude::*, twilight_exports::RoleMarker}; 7 | 8 | use super::handle_generic_error; 9 | 10 | // create a function that will take an array of things that implement to_string or debug 11 | // join them all on the provided separator 12 | // and, for the last one, use the word and instead of the separator 13 | // so, for example, if you have a list of 3 things, you'd get "a, b, and c" 14 | // if you have a list of 2 things, you'd get "a and b" 15 | // if you have a list of 1 thing, you'd get "a" 16 | // if you have a list of 0 things, you'd get "" 17 | fn join_with_and(items: &[T], separator: &str) -> String { 18 | match items.len() { 19 | 0 => "".to_string(), 20 | 1 => items[0].to_string(), 21 | 2 => format!("{} and {}", items[0], items[1]), 22 | _ => format!( 23 | "{} and {}", 24 | items[..items.len() - 1] 25 | .iter() 26 | .map(|i| i.to_string()) 27 | .collect::>() 28 | .join(separator), 29 | items[items.len() - 1] 30 | ), 31 | } 32 | } 33 | 34 | #[tracing::instrument(skip(ctx))] 35 | #[command] 36 | #[description = "Add all people from a provided role to this thread."] 37 | #[only_guilds] 38 | #[error_handler(handle_generic_error)] 39 | pub async fn add_role( 40 | ctx: &SlashContext, 41 | #[description = "Which role to add from"] role: Id, 42 | ) -> DefaultCommandResult { 43 | let _settings = get_settings(ctx.data, ctx.interaction.guild_id.unwrap()).await?; 44 | 45 | let members = ctx 46 | .http_client() 47 | .guild_members(ctx.interaction.guild_id.unwrap()) 48 | .limit(500) 49 | .unwrap() 50 | .await 51 | .unwrap() 52 | .model() 53 | .await 54 | .unwrap(); 55 | let role_members = members 56 | .into_iter() 57 | .filter(|m| m.roles.contains(&role)) 58 | .collect::>(); 59 | 60 | for member in &role_members { 61 | let res = ctx 62 | .http_client() 63 | .add_thread_member(ctx.interaction.channel.clone().unwrap().id, member.user.id) 64 | .await; 65 | if let Err(e) = res { 66 | tracing::warn!(?e, "error adding thread member"); 67 | } 68 | } 69 | 70 | let content = format!( 71 | "Successfully added {} member{} to the thread: {}", 72 | role_members.len(), 73 | // plurality 74 | if role_members.len() == 1 { "" } else { "s" }, 75 | join_with_and( 76 | &role_members 77 | .iter() 78 | .map(|m| format!("<@{}>", m.user.id)) 79 | .collect::>(), 80 | ", " 81 | ) 82 | ); 83 | 84 | ctx.interaction_client 85 | .create_response( 86 | ctx.interaction.id, 87 | &ctx.interaction.token, 88 | &InteractionResponse { 89 | kind: InteractionResponseType::ChannelMessageWithSource, 90 | data: Some(InteractionResponseData { 91 | content: Some(content), 92 | flags: None, 93 | allowed_mentions: Some(AllowedMentions::default()), 94 | ..Default::default() 95 | }), 96 | }, 97 | ) 98 | .await?; 99 | 100 | Ok(()) 101 | } 102 | -------------------------------------------------------------------------------- /chuckle-interactions/src/context_menu/circle_back.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::ChuckleState; 2 | use ms::*; 3 | use time::{Duration, OffsetDateTime}; 4 | use twilight_model::application::interaction::InteractionData; 5 | use vesper::prelude::*; 6 | 7 | use crate::commands::{create_followup, handle_generic_error, user_from_interaction}; 8 | 9 | #[derive(Modal, Debug)] 10 | #[modal(title = "Circle Back")] 11 | struct CircleBackModal { 12 | #[modal( 13 | label = "How long until you'd like to be notified?", 14 | placeholder = "20m, 1hr, 3hr, 12hr, 2d, etc.", 15 | min_length = 2, 16 | max_length = 10 17 | )] 18 | til_notify: String, 19 | } 20 | 21 | #[command(message, name = "Circle Back")] 22 | #[description = "Circle back to this message in a given amount of time"] 23 | #[only_guilds] 24 | #[error_handler(handle_generic_error)] 25 | pub async fn circle_back(ctx: &SlashContext) -> DefaultCommandResult { 26 | let data = match &ctx.interaction.data { 27 | Some(InteractionData::ApplicationCommand(data)) => data, 28 | _ => return Ok(()), 29 | }; 30 | let (message_id, message) = data 31 | .clone() 32 | .resolved 33 | .unwrap() 34 | .messages 35 | .into_iter() 36 | .next() 37 | .unwrap(); 38 | let author_id = message.author.id; 39 | 40 | let modal_waiter = ctx.create_modal::().await?; 41 | let output = modal_waiter.await?; 42 | 43 | let time = if let Some(time) = ms!(&output.til_notify) { 44 | time 45 | } else { 46 | return create_followup( 47 | ctx, 48 | "The duration you provided was invalid. Please try again with something like [this](https://github.com/nesso99/ms-rust/blob/5a579c9f5b45851086ace2bfa506f541e49b3bbd/tests/main.rs#L6-L22)".to_string(), 49 | false, 50 | ) 51 | .await; 52 | }; 53 | 54 | let notify_at = OffsetDateTime::now_utc() + Duration::milliseconds(time as i64); 55 | let _ = sqlx::query!( 56 | "insert into notifications (author_id, user_id, guild_id, channel_id, message_id, notify_at) values ($1, $2, $3, $4, $5, $6) returning id", 57 | author_id.get() as i64, 58 | user_from_interaction(&ctx.interaction).id.get() as i64, 59 | ctx.interaction.guild_id.unwrap().get() as i64, 60 | ctx.interaction.channel.clone().unwrap().id.get() as i64, 61 | message_id.get() as i64, 62 | notify_at 63 | ).fetch_one(&ctx.data.db).await?; 64 | 65 | create_followup( 66 | ctx, 67 | format!("Sounds good! I'll remind you in {}.", ms!(time, true)), 68 | true, 69 | ) 70 | .await 71 | } 72 | -------------------------------------------------------------------------------- /chuckle-interactions/src/context_menu/mod.rs: -------------------------------------------------------------------------------- 1 | mod circle_back; 2 | 3 | pub use circle_back::circle_back; 4 | -------------------------------------------------------------------------------- /chuckle-interactions/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::unused_unit)] // wtf 2 | use std::sync::Arc; 3 | 4 | use chuckle_util::{ 5 | state::{ChuckleState, State}, 6 | CONFIG, 7 | }; 8 | use twilight_model::id::marker::ApplicationMarker; 9 | use twilight_model::id::Id; 10 | use vesper::prelude::Framework; 11 | 12 | use self::commands::{breakout_rooms, config, hexil, link_github, ping, pr_comments, threads}; 13 | 14 | pub mod commands; 15 | pub mod context_menu; 16 | 17 | pub type ChuckleFramework = Arc>; 18 | 19 | pub fn crate_framework(state: ChuckleState) -> anyhow::Result { 20 | let http_client = State::http_client(); 21 | let app_id = Id::::new(CONFIG.discord_application_id.parse()?); 22 | 23 | let framework = Framework::builder(http_client, app_id, state) 24 | .group(|g| { 25 | g.name("config") 26 | .description("Configure the bot for your server.") 27 | .group(|sub| { 28 | sub.name("display") 29 | .description("List the configuration.") 30 | .command(config::list) 31 | }) 32 | .group(|sub| { 33 | sub.name("breakout-category") 34 | .description("Configure the breakout category.") 35 | .command(config::breakout_category::list) 36 | .command(config::breakout_category::set) 37 | .command(config::breakout_category::unset) 38 | }) 39 | .group(|sub| { 40 | sub.name("forum-log") 41 | .description("Configure the forum log channel.") 42 | .command(config::forum_log::list) 43 | .command(config::forum_log::set) 44 | .command(config::forum_log::unset) 45 | }) 46 | .group(|sub| { 47 | sub.name("default-org") 48 | .description("Configure the default GitHub organization for PR Comments.") 49 | .command(config::default_org::list) 50 | .command(config::default_org::set) 51 | .command(config::default_org::unset) 52 | }) 53 | .group(|sub| { 54 | sub.name("default-repo") 55 | .description("Configure the default GitHub repository for PR Comments.") 56 | .command(config::default_repo::list) 57 | .command(config::default_repo::set) 58 | .command(config::default_repo::unset) 59 | }) 60 | }) 61 | .group(|g| { 62 | g.name("breakout-rooms") 63 | .description("Commands for managing breakout rooms.") 64 | .command(breakout_rooms::create) 65 | .command(breakout_rooms::destroy) 66 | }) 67 | .group(|g| { 68 | g.name("threads") 69 | .description("Commands for managing threads.") 70 | .command(threads::add_role) 71 | }) 72 | .command(hexil) 73 | .command(link_github) 74 | .command(ping) 75 | .command(pr_comments) 76 | .command(context_menu::circle_back) 77 | .build(); 78 | 79 | Ok(Arc::new(framework)) 80 | } 81 | 82 | // create the commands lockfile 83 | #[cfg(feature = "lockfile")] 84 | pub async fn create_lockfile() -> anyhow::Result { 85 | let state = Arc::new(State::new().await); 86 | let framework = crate_framework(state)?; 87 | 88 | let commands = framework.twilight_commands(); 89 | println!("{:#?}", commands); 90 | 91 | let json = serde_json::to_string_pretty(&commands)?; 92 | 93 | Ok(json) 94 | } 95 | -------------------------------------------------------------------------------- /chuckle-interactions/src/main.rs: -------------------------------------------------------------------------------- 1 | use chuckle_interactions::create_lockfile; 2 | 3 | #[cfg(feature = "lockfile")] 4 | #[tokio::main] 5 | async fn main() -> anyhow::Result<()> { 6 | let content = create_lockfile() 7 | .await 8 | .expect("Failed to create lockfile content."); 9 | 10 | let path = concat!(env!("CARGO_MANIFEST_DIR"), "/commands.lock.json").to_string(); 11 | std::fs::write(path, content).unwrap(); 12 | 13 | Ok(()) 14 | } 15 | -------------------------------------------------------------------------------- /chuckle-jobs/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chuckle-jobs" 3 | description = "A job scheduler for Chuckle" 4 | version = { workspace = true } 5 | license = { workspace = true } 6 | edition = { workspace = true } 7 | authors = { workspace = true } 8 | repository = { workspace = true } 9 | homepage = { workspace = true } 10 | 11 | [dependencies] 12 | chuckle-util = { workspace = true } 13 | 14 | anyhow = { workspace = true } 15 | clokwerk = "0.4" 16 | sqlx = { workspace = true } 17 | tokio = { workspace = true, features = ["time", "rt"] } 18 | tracing = { workspace = true } 19 | twilight-model = { workspace = true } 20 | -------------------------------------------------------------------------------- /chuckle-jobs/src/circle_back.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::ChuckleState; 2 | use twilight_model::id::{marker::UserMarker, Id}; 3 | 4 | pub async fn run(state: &ChuckleState) -> anyhow::Result<()> { 5 | let rows = sqlx::query!( 6 | "select id, user_id, author_id, guild_id, channel_id, message_id, notify_at from notifications where notify_at < now() and completed = false", 7 | ) 8 | .fetch_all(&state.db).await?; 9 | 10 | for row in rows { 11 | // change `completed` to `true` 12 | sqlx::query!( 13 | "update notifications set completed = true where id = $1", 14 | row.id 15 | ) 16 | .execute(&state.db) 17 | .await?; 18 | 19 | let dm_channel = state 20 | .http_client 21 | .create_private_channel(Id::::new(row.user_id as u64)) 22 | .await? 23 | .model() 24 | .await?; 25 | 26 | state 27 | .http_client 28 | .create_message(dm_channel.id) 29 | .content( 30 | format!( 31 | "Time to circle back to https://discord.com/channels/{}/{}/{} from <@{}>!", 32 | row.guild_id, row.channel_id, row.message_id, row.author_id 33 | ) 34 | .as_str(), 35 | )? 36 | .await?; 37 | } 38 | 39 | Ok(()) 40 | } 41 | -------------------------------------------------------------------------------- /chuckle-jobs/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use chuckle_util::ChuckleState; 4 | use clokwerk::{AsyncScheduler, TimeUnits}; 5 | use tokio::time::sleep; 6 | 7 | mod circle_back; 8 | mod sweep_notifications; 9 | 10 | pub async fn start(state: ChuckleState) { 11 | tracing::debug!("Starting workers"); 12 | 13 | let mut scheduler = AsyncScheduler::new(); 14 | 15 | let state_clone = state.clone(); 16 | scheduler.every(30.seconds()).run(move || { 17 | let state = state_clone.clone(); 18 | async move { 19 | circle_back::run(&state).await.unwrap(); 20 | } 21 | }); 22 | 23 | let state_clone = state.clone(); 24 | scheduler.every(5.minutes()).run(move || { 25 | let state = state_clone.clone(); 26 | async move { 27 | sweep_notifications::run(&state).await.unwrap(); 28 | } 29 | }); 30 | 31 | loop { 32 | scheduler.run_pending().await; 33 | sleep(Duration::from_millis(500)).await; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /chuckle-jobs/src/sweep_notifications.rs: -------------------------------------------------------------------------------- 1 | use chuckle_util::ChuckleState; 2 | 3 | /// Sweep completed `notifications` rows older than 24 hours. 4 | pub async fn run(state: &ChuckleState) -> anyhow::Result<()> { 5 | sqlx::query("delete from notifications where completed = true and notify_at < now() - interval '24 hours';") 6 | .execute(&state.db) 7 | .await?; 8 | 9 | Ok(()) 10 | } 11 | -------------------------------------------------------------------------------- /chuckle-util/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chuckle-util" 3 | description = "Utilities for Chuckle" 4 | version = { workspace = true } 5 | license = { workspace = true } 6 | edition = { workspace = true } 7 | authors = { workspace = true } 8 | repository = { workspace = true } 9 | homepage = { workspace = true } 10 | 11 | [dependencies] 12 | anyhow = { workspace = true } 13 | once_cell = { workspace = true } 14 | vesper = { workspace = true } 15 | 16 | # config 17 | envy = { version = "0.4.2", optional = true } 18 | serde = { workspace = true, optional = true } 19 | 20 | # state 21 | sqlx = { workspace = true } 22 | time = { workspace = true } 23 | twilight-cache-inmemory = { workspace = true } 24 | twilight-http = { workspace = true } 25 | twilight-model = { workspace = true } 26 | uuid = { workspace = true } 27 | 28 | [features] 29 | default = ["config", "state"] 30 | config = ["dep:envy", "dep:serde"] 31 | state = ["config"] 32 | -------------------------------------------------------------------------------- /chuckle-util/src/chunkify.rs: -------------------------------------------------------------------------------- 1 | use vesper::prelude::Parse; 2 | 3 | #[derive(Parse, Debug)] 4 | pub enum RemainderStrategy { 5 | Overflow, 6 | Exclude, 7 | } 8 | 9 | pub struct ChunkRemainder { 10 | pub chunks: Vec>, 11 | pub excluded: Vec, 12 | } 13 | 14 | impl ChunkRemainder { 15 | fn new(chunks: Vec>, excluded: Vec) -> Self { 16 | Self { chunks, excluded } 17 | } 18 | } 19 | 20 | pub fn chunkify( 21 | vec: Vec, 22 | chunk_size: usize, 23 | strategy: RemainderStrategy, 24 | ) -> ChunkRemainder { 25 | if chunk_size == 0 { 26 | panic!("chunk_size must be greater than 0"); 27 | } 28 | 29 | let mut chunks = vec 30 | .iter() 31 | .enumerate() 32 | .map(|(i, item)| (i / chunk_size, item.clone())) 33 | .fold( 34 | vec![Vec::new(); vec.len() / chunk_size + 1], 35 | |mut acc, (idx, item)| { 36 | acc[idx].push(item); 37 | acc 38 | }, 39 | ); 40 | 41 | match strategy { 42 | RemainderStrategy::Overflow => { 43 | let overflow_items = chunks.pop().unwrap_or_default(); 44 | let mut overflow_iter = overflow_items.into_iter().rev(); 45 | for chunk in chunks.iter_mut().rev() { 46 | if let Some(value) = overflow_iter.next() { 47 | chunk.push(value); 48 | } else { 49 | break; 50 | } 51 | } 52 | ChunkRemainder::new(chunks, Vec::new()) 53 | } 54 | RemainderStrategy::Exclude => { 55 | let remainder = chunks.pop().unwrap_or_default(); 56 | ChunkRemainder::new(chunks, remainder) 57 | } 58 | } 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | 65 | #[test] 66 | fn test_overflow() { 67 | let v = vec![1, 2, 3, 4, 5, 6, 7]; 68 | let result = chunkify(v, 3, RemainderStrategy::Overflow); 69 | assert_eq!(result.chunks, vec![vec![1, 2, 3], vec![4, 5, 6, 7]]); 70 | assert_eq!(result.excluded, Vec::::new()); 71 | } 72 | 73 | fn irange(start: i32, stop: i32) -> Vec { 74 | (start..=stop).collect::>() 75 | } 76 | 77 | #[test] 78 | fn test_overflow_big() { 79 | // create a vec of usizes going from 1 to 35 80 | let v = irange(1, 35); 81 | let result = chunkify(v, 6, RemainderStrategy::Overflow); 82 | assert_eq!( 83 | result.chunks, 84 | vec![ 85 | vec![1, 2, 3, 4, 5, 6, 31], 86 | vec![7, 8, 9, 10, 11, 12, 32], 87 | vec![13, 14, 15, 16, 17, 18, 33], 88 | vec![19, 20, 21, 22, 23, 24, 34], 89 | vec![25, 26, 27, 28, 29, 30, 35], 90 | ] 91 | ); 92 | assert_eq!(result.excluded, Vec::::new()); 93 | } 94 | 95 | #[test] 96 | fn test_exclude() { 97 | let v = vec![1, 2, 3, 4, 5, 6, 7]; 98 | let result = chunkify(v, 3, RemainderStrategy::Exclude); 99 | assert_eq!(result.chunks, vec![vec![1, 2, 3], vec![4, 5, 6]]); 100 | assert_eq!(result.excluded, vec![7]); 101 | } 102 | 103 | #[test] 104 | #[should_panic(expected = "chunk_size must be greater than 0")] 105 | fn test_invalid_chunk_size() { 106 | let v = vec![1, 2, 3, 4, 5, 6, 7]; 107 | chunkify(v, 0, RemainderStrategy::Exclude); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /chuckle-util/src/config.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | pub static CONFIG: Lazy = Lazy::new(|| Config::new().expect("Unable to retrieve config")); 5 | 6 | /// Application Config 7 | #[derive(Debug, Serialize, Deserialize, Clone)] 8 | pub struct Config { 9 | /// The port to run the server on 10 | pub port: u16, 11 | /// The environment 12 | pub env: String, 13 | /// The Discord token 14 | pub discord_token: String, 15 | /// The Discord ID 16 | pub discord_application_id: String, 17 | /// The database URL 18 | pub database_url: String, 19 | /// The GitHub webhook secret 20 | pub github_webhook_secret: String, 21 | /// The GitHub token 22 | pub github_access_token: String, 23 | } 24 | 25 | impl Config { 26 | /// Create a new `Config` 27 | pub fn new() -> anyhow::Result { 28 | let config = envy::from_env::()?; 29 | 30 | Ok(config) 31 | } 32 | 33 | pub fn is_dev(&self) -> bool { 34 | self.env == "development" 35 | } 36 | } 37 | 38 | /// Get the default static `Config` 39 | pub fn get_config() -> &'static Config { 40 | &CONFIG 41 | } 42 | -------------------------------------------------------------------------------- /chuckle-util/src/db.rs: -------------------------------------------------------------------------------- 1 | use crate::{ChuckleState, Timestamptz}; 2 | use twilight_model::id::{marker::GuildMarker, Id}; 3 | use uuid::Uuid; 4 | 5 | #[derive(Debug)] 6 | pub struct GuildSettingsRow { 7 | pub id: Uuid, 8 | pub guild_id: String, 9 | pub breakout_rooms_category_id: Option, 10 | pub forum_log_channel_id: Option, 11 | pub default_repository: Option, 12 | pub default_repository_owner: Option, 13 | pub created_at: Timestamptz, 14 | } 15 | 16 | /// Fetches the guild settings, and creates a new entry if it doesn't exist. 17 | pub async fn get_settings( 18 | state: &ChuckleState, 19 | guild_id: Id, 20 | ) -> anyhow::Result { 21 | let entry = sqlx::query_as!( 22 | GuildSettingsRow, 23 | "select * from guild_settings where guild_id = $1", 24 | guild_id.to_string() 25 | ) 26 | .fetch_optional(&state.db) 27 | .await?; 28 | 29 | if entry.is_none() { 30 | Ok(sqlx::query_as!( 31 | GuildSettingsRow, 32 | "insert into guild_settings (guild_id) values ($1) returning *", 33 | guild_id.to_string() 34 | ) 35 | .fetch_one(&state.db) 36 | .await?) 37 | } else { 38 | Ok(entry.unwrap()) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /chuckle-util/src/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod config; 2 | pub use config::{get_config, CONFIG}; 3 | 4 | pub mod chunkify; 5 | 6 | pub mod db; 7 | 8 | pub mod state; 9 | pub use state::ChuckleState; 10 | 11 | pub mod timestamptz; 12 | pub use timestamptz::Timestamptz; 13 | -------------------------------------------------------------------------------- /chuckle-util/src/state.rs: -------------------------------------------------------------------------------- 1 | use crate::CONFIG; 2 | use once_cell::sync::Lazy; 3 | use std::sync::Arc; 4 | use std::{str::FromStr, time::Duration}; 5 | use twilight_cache_inmemory::InMemoryCache; 6 | use twilight_http::client::InteractionClient; 7 | use twilight_model::id::{marker::ApplicationMarker, Id}; 8 | 9 | pub type ChuckleState = Arc; 10 | 11 | #[derive(Clone)] 12 | pub struct State { 13 | pub cache: Arc, 14 | pub db: sqlx::PgPool, 15 | pub http_client: Arc, 16 | } 17 | 18 | impl State { 19 | pub async fn new() -> Self { 20 | let cache = Arc::new(InMemoryCache::builder().message_cache_size(10).build()); 21 | let db = sqlx::PgPool::connect(&CONFIG.database_url).await.unwrap(); 22 | 23 | Self { 24 | cache, 25 | db, 26 | http_client: Arc::new(Self::http_client()), 27 | } 28 | } 29 | 30 | pub fn http_client() -> twilight_http::Client { 31 | twilight_http::Client::builder() 32 | .token(CONFIG.discord_token.clone()) 33 | .timeout(Duration::from_secs(10)) 34 | .build() 35 | } 36 | 37 | pub fn interactions_client(&self) -> Arc { 38 | static ID: Lazy> = Lazy::new(|| { 39 | Id::::from_str(&CONFIG.discord_application_id).unwrap() 40 | }); 41 | 42 | Arc::new(self.http_client.interaction(*ID)) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /chuckle-util/src/timestamptz.rs: -------------------------------------------------------------------------------- 1 | use serde::de::Visitor; 2 | use serde::{Deserialize, Deserializer, Serialize, Serializer}; 3 | use std::fmt::Formatter; 4 | use time::format_description::well_known::Rfc3339; 5 | use time::{Duration, OffsetDateTime}; 6 | 7 | /// `OffsetDateTime` provides RFC-3339 (ISO-8601 subset) serialization, but the default 8 | /// `serde::Serialize` implementation produces array of integers, which is great for binary 9 | /// serialization, but infeasible to consume when returned from an API, and certainly 10 | /// not human-readable. 11 | /// 12 | /// With this wrapper type, we override this to provide the serialization format we want. 13 | /// 14 | /// `chrono::DateTime` doesn't need this treatment, but Chrono sadly seems to have stagnated, 15 | /// and has a few more papercuts than I'd like: 16 | /// 17 | /// * Having to import both `DateTime` and `Utc` everywhere gets annoying quickly. 18 | /// * lack of `const fn` constructors anywhere (especially for `chrono::Duration`) 19 | /// * `cookie::CookieBuilder` (used by Actix-web and `tower-cookies`) bakes-in `time::Duration` 20 | /// for setting the expiration 21 | /// * not really Chrono's fault but certainly doesn't help. 22 | #[derive(sqlx::Type, Debug, Clone)] 23 | pub struct Timestamptz(pub OffsetDateTime); 24 | 25 | impl From for Timestamptz { 26 | fn from(dt: OffsetDateTime) -> Self { 27 | Self(dt) 28 | } 29 | } 30 | 31 | impl From> for Timestamptz { 32 | fn from(dt: Option) -> Self { 33 | match dt { 34 | Some(dt) => Self(dt), 35 | None => Self(OffsetDateTime::now_utc()), 36 | } 37 | } 38 | } 39 | 40 | impl Timestamptz { 41 | pub fn now() -> Self { 42 | Self(OffsetDateTime::now_utc()) 43 | } 44 | 45 | pub fn with_seconds(seconds: i64) -> Self { 46 | Self(OffsetDateTime::now_utc() + Duration::seconds(seconds)) 47 | } 48 | } 49 | 50 | impl Serialize for Timestamptz { 51 | fn serialize(&self, serializer: S) -> Result 52 | where 53 | S: Serializer, 54 | { 55 | serializer.collect_str(&self.0.format(&Rfc3339).unwrap()) 56 | } 57 | } 58 | 59 | impl<'de> Deserialize<'de> for Timestamptz { 60 | fn deserialize(deserializer: D) -> Result 61 | where 62 | D: Deserializer<'de>, 63 | { 64 | struct StrVisitor; 65 | 66 | // By providing our own `Visitor` impl, we can access the string data without copying. 67 | // 68 | // We could deserialize a borrowed `&str` directly but certain deserialization modes 69 | // of `serde_json` don't support that, so we'd be forced to always deserialize `String`. 70 | // 71 | // `serde_with` has a helper for this but it can be a bit overkill to bring in 72 | // just for one type: https://docs.rs/serde_with/latest/serde_with/#displayfromstr 73 | // 74 | // We'd still need to implement `Display` and `FromStr`, but those are much simpler 75 | // to work with. 76 | // 77 | // However, I also wanted to demonstrate that it was possible to do this with Serde alone. 78 | impl Visitor<'_> for StrVisitor { 79 | type Value = Timestamptz; 80 | 81 | fn expecting(&self, f: &mut Formatter) -> std::fmt::Result { 82 | f.pad("expected string") 83 | } 84 | 85 | fn visit_str(self, v: &str) -> Result 86 | where 87 | E: serde::de::Error, 88 | { 89 | OffsetDateTime::parse(v, &Rfc3339) 90 | .map(Timestamptz) 91 | .map_err(E::custom) 92 | } 93 | } 94 | 95 | deserializer.deserialize_str(StrVisitor) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /chuckle/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chuckle" 3 | description = "The primary Chuckle binary" 4 | version = { workspace = true } 5 | license = { workspace = true } 6 | edition = { workspace = true } 7 | authors = { workspace = true } 8 | repository = { workspace = true } 9 | homepage = { workspace = true } 10 | 11 | [dependencies] 12 | chuckle-gateway = { workspace = true } 13 | chuckle-github = { workspace = true } 14 | chuckle-http = { workspace = true } 15 | chuckle-interactions = { workspace = true } 16 | chuckle-jobs = { workspace = true } 17 | chuckle-util = { workspace = true } 18 | 19 | anyhow = { workspace = true } 20 | tokio = { workspace = true } 21 | tracing = { workspace = true } 22 | tracing-subscriber = { workspace = true } 23 | -------------------------------------------------------------------------------- /chuckle/Dockerfile: -------------------------------------------------------------------------------- 1 | # Using the `rust-musl-builder` as base image, instead of 2 | # the official Rust toolchain 3 | FROM clux/muslrust:stable AS chef 4 | USER root 5 | RUN cargo install cargo-chef 6 | WORKDIR /app 7 | 8 | FROM chef AS planner 9 | COPY . . 10 | RUN cargo chef prepare --recipe-path recipe.json 11 | 12 | FROM chef AS builder 13 | COPY --from=planner /app/recipe.json recipe.json 14 | # Notice that we are specifying the --target flag! 15 | RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json 16 | COPY . . 17 | ENV SQLX_OFFLINE true 18 | RUN cargo build --release --target x86_64-unknown-linux-musl --bin chuckle 19 | 20 | FROM alpine AS runtime 21 | WORKDIR /app 22 | # RUN addgroup -S myuser && adduser -S myuser -G myuser 23 | COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/chuckle . 24 | COPY --from=builder /app/migrations migrations 25 | RUN ls -la 26 | # USER myuser 27 | CMD ["/app/chuckle"] 28 | -------------------------------------------------------------------------------- /chuckle/src/main.rs: -------------------------------------------------------------------------------- 1 | use chuckle_interactions::crate_framework; 2 | use chuckle_util::{state::State, ChuckleState}; 3 | use std::sync::Arc; 4 | use tracing_subscriber::prelude::*; 5 | 6 | #[tokio::main] 7 | async fn main() -> anyhow::Result<()> { 8 | let registry = tracing_subscriber::registry().with( 9 | tracing_subscriber::EnvFilter::try_from_default_env() 10 | .unwrap_or_else(|_| "debug,hyper=info,tower_http=info,rustls=info".into()), 11 | ); 12 | 13 | // #[cfg(not(debug_assertions))] 14 | // let registry = registry.with(tracing_subscriber::fmt::layer().json()); 15 | // #[cfg(debug_assertions)] 16 | let registry = registry.with(tracing_subscriber::fmt::layer()); 17 | 18 | registry.init(); 19 | 20 | let state: ChuckleState = Arc::new(State::new().await); 21 | let framework = crate_framework(state.clone()).expect("Failed to create zephryus framework"); 22 | 23 | let commands = framework 24 | .commands 25 | .values() 26 | .map(|c| c.name) 27 | .collect::>() 28 | .join(", "); 29 | tracing::info!("Loaded commands: {:#?}", commands); 30 | 31 | let groups = framework 32 | .groups 33 | .values() 34 | .map(|g| g.name) 35 | .collect::>() 36 | .join(", "); 37 | tracing::info!("Loaded groups: {:#?}", groups); 38 | 39 | tokio::spawn(chuckle_jobs::start(state.clone())); 40 | tokio::spawn(chuckle_http::serve(state.clone())); 41 | let _ = tokio::spawn(chuckle_gateway::create_gateway(state, framework)).await; 42 | 43 | Ok(()) 44 | } 45 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | 3 | services: 4 | postgres: 5 | image: postgres:alpine 6 | environment: 7 | POSTGRES_USER: 'admin' 8 | POSTGRES_PASSWORD: 'oafishcaveman' 9 | POSTGRES_DB: 'chuckle' 10 | volumes: 11 | - postgres-data:/var/lib/postgresql/data 12 | restart: unless-stopped 13 | ports: 14 | - 127.0.0.1:5432:5432 15 | healthcheck: 16 | test: ['CMD-SHELL', 'pg_isready -U admin -d chuckle'] 17 | interval: 10s 18 | timeout: 5s 19 | 20 | chuckle: 21 | build: 22 | context: . 23 | dockerfile: ./chuckle/Dockerfile 24 | environment: 25 | - DATABASE_URL 26 | - ENV 27 | - DISCORD_APPLICATION_ID 28 | - DISCORD_TOKEN 29 | - RUST_LOG 30 | - FORUM_LOG_CHANNEL 31 | - GITHUB_WEBHOOK_SECRET 32 | - PORT 33 | - GITHUB_ACCESS_TOKEN 34 | 35 | volumes: 36 | postgres-data: 37 | -------------------------------------------------------------------------------- /migrations/0_init.sql: -------------------------------------------------------------------------------- 1 | create table if not exists settings ( 2 | id uuid primary key not null default gen_random_uuid(), 3 | guild_id bigint not null, 4 | forum_log_channel_id bigint not null 5 | ); 6 | 7 | create table if not exists notifications ( 8 | id uuid primary key not null default gen_random_uuid(), 9 | user_id bigint not null, 10 | guild_id bigint not null, 11 | author_id bigint not null, 12 | channel_id bigint not null, 13 | message_id bigint not null, 14 | completed boolean not null default false, 15 | notify_at timestamptz not null, 16 | created_at timestamptz not null default now() 17 | ); 18 | -------------------------------------------------------------------------------- /migrations/1_modal.sql: -------------------------------------------------------------------------------- 1 | create table if not exists modal ( 2 | id uuid primary key not null default gen_random_uuid(), 3 | command text not null, 4 | meta jsonb, 5 | created_at timestamptz not null default now() 6 | ); 7 | -------------------------------------------------------------------------------- /migrations/2_pr_review.sql: -------------------------------------------------------------------------------- 1 | create table if not exists "user" ( 2 | id uuid primary key not null default gen_random_uuid(), 3 | discord_id text, 4 | -- such as 45381083 5 | github_id int, 6 | created_at timestamptz not null default now() 7 | ); 8 | 9 | create table if not exists pr_review_output ( 10 | id uuid primary key not null default gen_random_uuid(), 11 | pr_number int not null, 12 | repo_owner text not null, 13 | repo text not null, 14 | thread_id text not null, 15 | created_at timestamptz not null default now() 16 | ); 17 | -------------------------------------------------------------------------------- /migrations/3_hexil.sql: -------------------------------------------------------------------------------- 1 | create table if not exists hexil ( 2 | id uuid primary key not null default gen_random_uuid(), 3 | guild_id text not null, 4 | user_id text not null, 5 | role_id text not null, 6 | created_at timestamptz not null default now() 7 | ); 8 | -------------------------------------------------------------------------------- /migrations/4_guild_settings.sql: -------------------------------------------------------------------------------- 1 | create table if not exists guild_settings ( 2 | id uuid primary key not null default gen_random_uuid(), 3 | guild_id text not null, 4 | forum_log_channel_id text, 5 | default_repository text, 6 | default_repository_owner text, 7 | created_at timestamptz not null default now() 8 | ); 9 | -------------------------------------------------------------------------------- /migrations/5_breakout_rooms.sql: -------------------------------------------------------------------------------- 1 | alter table guild_settings add breakout_rooms_category_id text; 2 | -------------------------------------------------------------------------------- /migrations/6_drop_modal.sql: -------------------------------------------------------------------------------- 1 | drop table modal; 2 | -------------------------------------------------------------------------------- /migrations/README.md: -------------------------------------------------------------------------------- 1 | # migrations 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | --------------------------------------------------------------------------------