├── .env.example ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ ├── docker-publish.yml │ └── tests.yaml ├── .gitignore ├── .prettierrc.yaml ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── bin ├── dev-setup.sh ├── discord-ttl └── update-ttl.sh ├── bun.lockb ├── dev-docker-compose.yaml ├── docker-compose.yaml ├── drizzle.config.ts ├── drizzle ├── 0000_blue_dust.sql └── meta │ ├── 0000_snapshot.json │ └── _journal.json ├── package.json ├── setup.sh ├── src ├── app.ts ├── bot │ ├── api.ts │ ├── commands │ │ ├── default-ttl │ │ │ ├── reset │ │ │ │ ├── current-channel.ts │ │ │ │ └── server-wide.ts │ │ │ └── set │ │ │ │ ├── current-channel.ts │ │ │ │ └── server-wide.ts │ │ ├── my-ttl │ │ │ ├── reset │ │ │ │ ├── current-channel.ts │ │ │ │ └── server-wide.ts │ │ │ └── set │ │ │ │ ├── current-channel.ts │ │ │ │ └── server-wide.ts │ │ └── ttl.ts │ ├── common │ │ └── utils.ts │ ├── cookie.ts │ └── core.ts ├── common │ ├── lock.ts │ └── types.ts ├── database │ ├── api.ts │ ├── cache.ts │ ├── db.ts │ ├── tables.ts │ └── tests │ │ └── api.test.ts └── logger.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | #### 2 | ## DISCORD_BOT_TOKEN 3 | ## 4 | # This is an access token that will allow Discord TTL to run on a Discord bot of your choice. 5 | # See https://ayu.dev/r/discord-bot-token-guide to learn how to generate a Discord bot token. 6 | ## 7 | #### 8 | DISCORD_BOT_TOKEN= 9 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | extends: [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | "plugin:import/errors", 11 | "plugin:import/warnings", 12 | "plugin:import/typescript", 13 | ], 14 | parser: "@typescript-eslint/parser", 15 | parserOptions: { 16 | project: "tsconfig.json", 17 | sourceType: "module", 18 | }, 19 | plugins: [ 20 | "@typescript-eslint", 21 | ], 22 | // settings: { 23 | // "import/resolver": { 24 | // typescript: true, 25 | // } 26 | // }, 27 | rules: { 28 | "@typescript-eslint/array-type": "error", 29 | "@typescript-eslint/ban-types": "off", 30 | "@typescript-eslint/consistent-type-assertions": "error", 31 | "@typescript-eslint/consistent-type-definitions": ["error", "interface"], 32 | "@typescript-eslint/dot-notation": "error", 33 | "@typescript-eslint/explicit-member-accessibility": "error", 34 | "@typescript-eslint/explicit-module-boundary-types": "off", 35 | "@typescript-eslint/member-delimiter-style": "error", 36 | "@typescript-eslint/no-explicit-any": "off", 37 | "@typescript-eslint/no-floating-promises": "error", 38 | "@typescript-eslint/no-inferrable-types": "off", 39 | "@typescript-eslint/no-namespace": "off", 40 | "@typescript-eslint/no-non-null-assertion": "off", 41 | "@typescript-eslint/no-shadow": ["error"], 42 | "@typescript-eslint/no-unsafe-assignment": "off", 43 | "@typescript-eslint/no-unsafe-call": "off", 44 | "@typescript-eslint/no-unsafe-member-access": "off", 45 | "@typescript-eslint/no-unsafe-return": "off", 46 | "@typescript-eslint/no-unused-expressions": "error", 47 | "no-unused-vars": "off", 48 | "@typescript-eslint/no-unused-vars": ["error", { 49 | vars: "all", 50 | args: "none", 51 | ignoreRestSiblings: false, 52 | }], 53 | "@typescript-eslint/prefer-for-of": "error", 54 | "@typescript-eslint/prefer-function-type": "error", 55 | "@typescript-eslint/prefer-regexp-exec": "off", 56 | "@typescript-eslint/require-await": "off", 57 | "@typescript-eslint/restrict-template-expressions": "off", 58 | "@typescript-eslint/type-annotation-spacing": "error", 59 | "@typescript-eslint/unified-signatures": "error", 60 | "eol-last": "error", 61 | "eqeqeq": "error", 62 | "guard-for-in": "error", 63 | "import/order": "error", 64 | // Handled by typescript 65 | "import/no-unresolved": "off", 66 | "new-parens": "error", 67 | "no-caller": "error", 68 | "no-constant-condition": "off", 69 | // https://typescript-eslint.io/rules/dot-notation/#how-to-use 70 | "dot-notation": "off", 71 | "no-eval": "error", 72 | "no-multiple-empty-lines": "error", 73 | "no-new-wrappers": "error", 74 | "no-throw-literal": "error", 75 | "no-trailing-spaces": "error", 76 | "no-undef-init": "error", 77 | "no-useless-rename": "error", 78 | "object-shorthand": "error", 79 | "one-var": ["error", "never"], 80 | "prefer-const": ["error", { 81 | destructuring: "all", 82 | }], 83 | "quote-props": ["error", "consistent-as-needed"], 84 | "radix": "error", 85 | }, 86 | }; 87 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | *.lockb binary diff=lockb 2 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | tags: [ 'v*.*.*' ] 11 | 12 | env: 13 | REGISTRY: ghcr.io 14 | IMAGE_NAME: ${{ github.repository }} 15 | 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ubuntu-latest 21 | permissions: 22 | contents: read 23 | packages: write 24 | # This is used to complete the identity challenge 25 | # with sigstore/fulcio when running outside of PRs. 26 | id-token: write 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v3 31 | 32 | # Install the cosign tool except on PR 33 | # https://github.com/sigstore/cosign-installer 34 | - name: Install cosign 35 | if: github.event_name != 'pull_request' 36 | uses: sigstore/cosign-installer@v3.5.0 37 | 38 | # Login against a Docker registry except on PR 39 | # https://github.com/docker/login-action 40 | - name: Log into registry ${{ env.REGISTRY }} 41 | if: github.event_name != 'pull_request' 42 | uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1 43 | with: 44 | registry: ${{ env.REGISTRY }} 45 | username: ${{ github.actor }} 46 | password: ${{ secrets.GITHUB_TOKEN }} 47 | 48 | # Extract metadata (tags, labels) for Docker 49 | # https://github.com/docker/metadata-action 50 | - name: Extract Docker metadata 51 | id: meta 52 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 53 | with: 54 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 55 | tags: | 56 | type=semver,pattern={{version}} 57 | type=semver,pattern={{major}}.{{minor}} 58 | type=semver,pattern={{major}} 59 | 60 | 61 | # Build and push Docker image with Buildx (don't push on PR) 62 | # https://github.com/docker/build-push-action 63 | - name: Build and push Docker image 64 | id: build-and-push 65 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 66 | with: 67 | context: . 68 | push: ${{ github.event_name != 'pull_request' }} 69 | tags: ${{ steps.meta.outputs.tags }} 70 | labels: ${{ steps.meta.outputs.labels }} 71 | 72 | # Sign the resulting Docker image digest except on PRs. 73 | # This will only write to the public Rekor transparency log when the Docker 74 | # repository is public to avoid leaking data. If you would like to publish 75 | # transparency data even for private images, pass --force to cosign below. 76 | # https://github.com/sigstore/cosign 77 | - name: Sign the published Docker image 78 | if: ${{ github.event_name != 'pull_request' }} 79 | env: 80 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 81 | TAGS: ${{ steps.meta.outputs.tags }} 82 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 83 | # This step uses the identity token to provision an ephemeral certificate 84 | # against the sigstore community Fulcio instance. 85 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 86 | -------------------------------------------------------------------------------- /.github/workflows/tests.yaml: -------------------------------------------------------------------------------- 1 | name: tests 2 | on: [push, workflow_dispatch] 3 | jobs: 4 | tests: 5 | name: tests 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: checkout repository 9 | uses: actions/checkout@v4 10 | 11 | - name: install bun :3 12 | uses: oven-sh/setup-bun@v1 13 | with: 14 | bun-version: latest 15 | 16 | - name: install dependencies 17 | run: bun install --frozen-lockfile 18 | 19 | - name: lint >:D 20 | run: bun lint 21 | 22 | - name: pretty :o 23 | run: bun pretty 24 | 25 | - name: compile !! 26 | run: bun compile 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/dist 2 | /node_modules/ 3 | .env 4 | yarn-error.log 5 | *.db 6 | /data/ 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | trailingComma: "all" 2 | tabWidth: 2 3 | semi: true 4 | singleQuote: true 5 | printWidth: 120 6 | bracketSpacing: true 7 | arrowParens: "avoid" 8 | overrides: 9 | - files: ["*.ts", "*.tsx"] 10 | options: 11 | parser: typescript 12 | - files: ["*.json"] 13 | options: 14 | tabWidth: 2 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.autoSave": "afterDelay", 3 | "files.autoSaveDelay": 1000, 4 | "editor.tabSize": 2, 5 | "prettier.enable": true, 6 | "prettier.configPath": "./.prettierrc.yaml", 7 | "[typescript]": { 8 | "editor.defaultFormatter": "dbaeumer.vscode-eslint", 9 | "editor.formatOnSave": true, 10 | "editor.rulers": [120] 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "vscode.typescript-language-features" 14 | }, 15 | "compile-hero.disable-compile-files-on-did-save-code": false 16 | } 17 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ### 2 | ### layer 1: install deps & compile typescript 3 | ### 4 | FROM oven/bun:alpine 5 | # 6 | WORKDIR /usr/app 7 | COPY bun.lockb package.json tsconfig.json /usr/app/ 8 | ADD drizzle /usr/app/drizzle 9 | ADD src /usr/app/src 10 | # 11 | RUN bun install 12 | RUN bun compile 13 | 14 | ### 15 | ### (final) 16 | ### layer 2: run 17 | ### 18 | FROM oven/bun:alpine 19 | # 20 | WORKDIR /usr/app 21 | COPY --from=0 /usr/app/node_modules /usr/app/node_modules 22 | COPY --from=0 /usr/app/dist /usr/app/dist 23 | COPY --from=0 /usr/app/drizzle /usr/app/drizzle 24 | # 25 | CMD bun run /usr/app/dist/app.js 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ayu 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 |

5 |

Discord TTL

6 |

7 | 8 | Discord TTL is a simple-to-selfhost Discord bot that protects privacy by deleting server messages older than a configurable TTL (time to live). 9 | TTLs can be configured for an entire server and/or specific channels. Individuals can also configure their own TTLs to override the default server or channel settings. 10 | 11 | Setting up Discord TTL via the setup script comes with auto-updates so that hosters can get all of the latest features automatically upon new GitHub releases. 12 | For those that wish to stay on a static version, auto-updates can be opted out of by adding a `--skip-auto-updater` flag to the setup script. An architecture 13 | diagram for the auto-updater can be found below. 14 | 15 | 16 | 17 |

18 |

Updater Architecture

19 |

20 |

21 | 22 |

23 | 24 |

25 |

How do I get Discord TTL for my server?

26 |

27 | 28 | 29 | There is currently no officially-maintained public Discord TTL bot. You can find a live (but private) version of this bot running at https://discord.gg/ayu. 30 | If you would like to self-host Discord TTL, follow the steps below: 31 | 32 |

33 |

If you are new to self-hosting

34 |

35 | 36 | To get started, you'll need a server to host the bot on. If you already have one, that's great! Do note that the setup script in this tutorial only works 37 | on Debian/Ubuntu & MacOS, but it is possible to get Discord TTL running on any OS so long as you can install Docker. 38 | 39 | If you do *not* have a server, a common & cheap option for self-hosting 24/7 is to purchase a VPS (virtual private server). You can find VPS providers all over the internet, 40 | each with their own pricings and server locations. GalaxyGate's Standard 1GB is a perfectly-capable USA-hosted option 41 | that might be within budget for most ($3/month), but any provider will work just as well. 42 | 43 | The last option for self-hosting would be locally on a PC. This option is not advised since it would cause your Discord TTL instance to go offline every time your PC 44 | is shut down or sleeping, but it is the cheapest option if you are not concerned with 100% availability. 45 | 46 |

47 |

Once you have a place to host

48 |

49 | 50 | Navigate to a suitable location to keep Discord TTL's files within. The home directory is a safe option: 51 | ```bash 52 | cd ~ 53 | ``` 54 | Then, clone this repository: 55 | ```bash 56 | git clone https://github.com/ayubun/discord-ttl 57 | ``` 58 | After cloning, navigate to the newly-created `discord-ttl` directory: 59 | ```bash 60 | cd discord-ttl 61 | ``` 62 | Lastly, run the `setup.sh` script. As mentioned earlier, this will install auto-updates. 63 | ```bash 64 | ./setup.sh 65 | ``` 66 | **If you wish to opt out of the auto updates, run this command to setup instead of the one above:** 67 | ```bash 68 | ./setup.sh --skip-auto-updater 69 | ``` 70 | 71 |

72 |

After running the setup script

73 |

74 | 75 | You will need a Discord bot token from the Discord Developers portal in order to run Discord TTL. If you don't know how to get one, check out [Discord.js's bot token guide](https://ayu.dev/r/discord-bot-token-guide). 76 | 77 | Once you get a bot token, add it to the `.env` file created from the setup script on the line that says `DISCORD_BOT_TOKEN=` by using your preferred text editor. If you are operating entirely from the 78 | command line and you are not sure how to edit files, `nano` is a safe option. [How-To Geek's nano guide](https://www.howtogeek.com/42980/the-beginners-guide-to-nano-the-linux-command-line-text-editor) might 79 | serve as a helpful starting point. 80 | 81 | Once the `.env` is saved with the bot token, you can start Discord TTL with `docker compose`: 82 | ```bash 83 | docker compose up -d 84 | ``` 85 | 86 | To create an invite link for your newly-hosted Discord TTL instance, check out [Discord.js's bot invite links guide](https://discordjs.guide/preparations/adding-your-bot-to-servers.html). 87 | -------------------------------------------------------------------------------- /bin/dev-setup.sh: -------------------------------------------------------------------------------- 1 | 2 | # TODOS: 3 | 4 | # bun setup 5 | # make sure to do the lockfile diffing thing too https://bun.sh/docs/install/lockfile 6 | 7 | # checkout main branch if possible? 8 | 9 | # install dependencies 10 | -------------------------------------------------------------------------------- /bin/discord-ttl: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # # ================ 4 | # # TERMINAL COLORS~ 5 | # # ================ 6 | # MOVE_UP=`tput cuu 1` 7 | # CLEAR_LINE=`tput el 1` 8 | # BOLD=`tput bold` 9 | # UNDERLINE=`tput smul` 10 | # RED_TEXT=`tput setaf 1` 11 | # GREEN_TEXT=`tput setaf 2` 12 | # YELLOW_TEXT=`tput setaf 3` 13 | # BLUE_TEXT=`tput setaf 4` 14 | # MAGENTA_TEXT=`tput setaf 5` 15 | # CYAN_TEXT=`tput setaf 6` 16 | # WHITE_TEXT=`tput setaf 7` 17 | # RESET=`tput sgr0` 18 | 19 | # echo "${RESET}${RED_TEXT}[${BOLD}ERROR${RESET}${RED_TEXT}]${RESET}${BOLD}${YELLOW_TEXT}" -------------------------------------------------------------------------------- /bin/update-ttl.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # ================ 4 | # TERMINAL COLORS~ 5 | # ================ 6 | BOLD=`tput bold` 7 | UNDERLINE=`tput smul` 8 | RED_TEXT=`tput setaf 1` 9 | GREEN_TEXT=`tput setaf 2` 10 | YELLOW_TEXT=`tput setaf 3` 11 | BLUE_TEXT=`tput setaf 4` 12 | RESET=`tput sgr0` 13 | 14 | CURRENT_DIR=$(pwd) 15 | # source: https://stackoverflow.com/questions/59895/how-do-i-get-the-directory-where-a-bash-script-is-located-from-within-the-script 16 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 17 | cd $SCRIPT_DIR && cd .. 18 | 19 | # ======================== 20 | # UPSTREAM VERSION CHECKER 21 | # ======================== 22 | 23 | # Reads the version number in package.json 24 | # src: https://gist.github.com/DarrenN/8c6a5b969481725a4413 25 | # + https://stackoverflow.com/questions/428109/extract-substring-in-bash to remove space in front 26 | CURRENT_VERSION=$(cat package.json \ 27 | | grep version \ 28 | | head -1 \ 29 | | awk -F: '{ print $2 }' \ 30 | | sed 's/[",]//g') 31 | CURRENT_VERSION=${CURRENT_VERSION#* } 32 | 33 | # Remove anything past the .'s in CURRENT_VERSION 34 | # src: https://stackoverflow.com/questions/428109/extract-substring-in-bash to remove space in front 35 | CURRENT_MAJOR_VERSION=${CURRENT_VERSION%\.*\.*} 36 | RAW_CONTENT_URL=https://raw.githubusercontent.com/ayubun/discord-ttl/v${CURRENT_MAJOR_VERSION} 37 | 38 | # Using the major version, grab the latest package.json version number on the ttl repo 39 | UPSTREAM_VERSION=$(curl -s ${RAW_CONTENT_URL}/package.json \ 40 | | grep version \ 41 | | head -1 \ 42 | | awk -F: '{ print $2 }' \ 43 | | sed 's/[",]//g') 44 | UPSTREAM_VERSION=${UPSTREAM_VERSION#* } 45 | 46 | # We will check this JUST in case the major versions are mismatched (this shouldn't happen but /shrug ppl make mistakes) 47 | UPSTREAM_MAJOR_VERSION=${UPSTREAM_VERSION%\.*\.*} 48 | 49 | if [[ $CURRENT_MAJOR_VERSION != $UPSTREAM_MAJOR_VERSION ]]; then 50 | echo "${RESET}${RED_TEXT}[${BOLD}ERROR${RESET}${RED_TEXT}]${RESET}${BOLD}${YELLOW_TEXT} The upstream major version is unexpected! The updater will only update the container(s) now.${RESET}" 51 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Pulling any new docker image(s)...${RESET}" 52 | docker compose pull 53 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Upping container(s)...${RESET}" 54 | docker compose up -d 55 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${GREEN_TEXT} Done! Discord TTL container(s) should now be up-to-date :)${RESET}" 56 | return 1 57 | fi 58 | 59 | if [[ $CURRENT_VERSION < $UPSTREAM_VERSION ]]; then 60 | # PULL UPDATED SCRIPTS 61 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Pulling updated scripts...${RESET}" 62 | SETUP_UPDATED=false 63 | if [[ $(curl -s ${RAW_CONTENT_URL}/setup.sh) != $(cat ./setup.sh) ]]; then 64 | SETUP_UPDATED=true 65 | fi 66 | # Currently unneeded \/ 67 | # UPDATER_UPDATED=false 68 | # if [[ $(curl -s ${RAW_CONTENT_URL}/bin/update-ttl.sh) != $(cat ./bin/update-ttl.sh) ]]; then 69 | # UPDATER_UPDATED=true 70 | # fi 71 | # BASH_CMD_UPDATED=false 72 | # if [[ $(curl -s ${RAW_CONTENT_URL}/bin/discord-ttl) != $(cat ./bin/discord-ttl) ]]; then 73 | # BASH_CMD_UPDATED=true 74 | # fi 75 | curl -s ${RAW_CONTENT_URL}/setup.sh > ./setup.sh 76 | curl -s ${RAW_CONTENT_URL}/bin/update-ttl.sh > ./bin/update-ttl.sh 77 | curl -s ${RAW_CONTENT_URL}/bin/discord-ttl > ./bin/discord-ttl 78 | # PULL UPDATED DOCKER COMPOSE 79 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Pulling updated docker compose...${RESET}" 80 | curl -s ${RAW_CONTENT_URL}/docker-compose.yaml > ./docker-compose.yaml 81 | # PULL UPDATED README & .ENV EXAMPLE (I mean, why not, right?) 82 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Pulling updated README & .env example...${RESET}" 83 | curl -s ${RAW_CONTENT_URL}/README.md > ./README.md 84 | curl -s ${RAW_CONTENT_URL}/.env.example > ./.env.example 85 | # PULL UPDATED PACKAGE.JSON (purely so the new version is reflected) 86 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Pulling updated package.json (to reflect the updated version)...${RESET}" 87 | curl -s ${RAW_CONTENT_URL}/package.json > ./package.json 88 | if [[ $SETUP_UPDATED == true ]]; then 89 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Running updated setup.sh...${RESET}" 90 | source ./setup.sh --skip-docker --skip-docker-compose 91 | fi 92 | else 93 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${GREEN_TEXT} No upstream updates found. Local scripts & files should be up-to-date!${RESET}" 94 | fi 95 | 96 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Pulling any new docker image(s)...${RESET}" 97 | docker compose pull 98 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Upping container(s)...${RESET}" 99 | docker compose up -d 100 | echo "${RESET}${YELLOW_TEXT}[${BOLD}TTL Updater${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${GREEN_TEXT} Done! Discord TTL should now be up-to-date :)${RESET}" 101 | 102 | # Return to whatever location this script was intitally run from for UX purposes 103 | cd $CURRENT_DIR 104 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ayubun/discord-ttl/d00036a2a4df8dbb612f9aa00192fbc29c369c07/bun.lockb -------------------------------------------------------------------------------- /dev-docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | discord-ttl: 5 | build: . 6 | restart: on-failure:3 7 | volumes: 8 | - ${PWD}/data:/usr/app/data 9 | env_file: 10 | - .env 11 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | discord-ttl: 5 | image: ghcr.io/ayubun/discord-ttl:1 6 | restart: always 7 | volumes: 8 | - ${PWD}/data:/usr/app/data 9 | env_file: 10 | - .env 11 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import type { Config } from 'drizzle-kit'; 3 | export default { 4 | schema: './src/database/tables.ts', 5 | out: './drizzle', 6 | driver: 'better-sqlite', // 'pg' | 'mysql2' | 'better-sqlite' | 'libsql' | 'turso' 7 | dbCredentials: { 8 | url: './data/discord-ttl.db', 9 | }, 10 | } satisfies Config; 11 | -------------------------------------------------------------------------------- /drizzle/0000_blue_dust.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `server_settings` ( 2 | `server_id` text NOT NULL, 3 | `channel_id` text, 4 | `default_message_ttl` integer, 5 | `max_message_ttl` integer, 6 | `min_message_ttl` integer, 7 | `include_pins_by_default` integer, 8 | PRIMARY KEY(`channel_id`, `server_id`) 9 | ); 10 | -------------------------------------------------------------------------------- /drizzle/meta/0000_snapshot.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "id": "0fcff181-d36a-4e80-94fa-fcf751c936e1", 5 | "prevId": "00000000-0000-0000-0000-000000000000", 6 | "tables": { 7 | "server_settings": { 8 | "name": "server_settings", 9 | "columns": { 10 | "server_id": { 11 | "name": "server_id", 12 | "type": "text", 13 | "primaryKey": false, 14 | "notNull": true, 15 | "autoincrement": false 16 | }, 17 | "channel_id": { 18 | "name": "channel_id", 19 | "type": "text", 20 | "primaryKey": false, 21 | "notNull": false, 22 | "autoincrement": false 23 | }, 24 | "default_message_ttl": { 25 | "name": "default_message_ttl", 26 | "type": "integer", 27 | "primaryKey": false, 28 | "notNull": false, 29 | "autoincrement": false 30 | }, 31 | "max_message_ttl": { 32 | "name": "max_message_ttl", 33 | "type": "integer", 34 | "primaryKey": false, 35 | "notNull": false, 36 | "autoincrement": false 37 | }, 38 | "min_message_ttl": { 39 | "name": "min_message_ttl", 40 | "type": "integer", 41 | "primaryKey": false, 42 | "notNull": false, 43 | "autoincrement": false 44 | }, 45 | "include_pins_by_default": { 46 | "name": "include_pins_by_default", 47 | "type": "integer", 48 | "primaryKey": false, 49 | "notNull": false, 50 | "autoincrement": false 51 | } 52 | }, 53 | "indexes": {}, 54 | "foreignKeys": {}, 55 | "compositePrimaryKeys": { 56 | "server_settings_server_id_channel_id_pk": { 57 | "columns": [ 58 | "channel_id", 59 | "server_id" 60 | ], 61 | "name": "server_settings_server_id_channel_id_pk" 62 | } 63 | }, 64 | "uniqueConstraints": {} 65 | } 66 | }, 67 | "enums": {}, 68 | "_meta": { 69 | "schemas": {}, 70 | "tables": {}, 71 | "columns": {} 72 | } 73 | } -------------------------------------------------------------------------------- /drizzle/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1722215301519, 9 | "tag": "0000_blue_dust", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "discord-ttl", 3 | "version": "1.1.1", 4 | "main": "dist/app.js", 5 | "repository": "git@github.com:ayubun/discord-ttl.git", 6 | "author": "ayu ", 7 | "license": "MIT", 8 | "packageManager": "bun@1.0.22", 9 | "pre-commit": [ 10 | "lint-fix-echo", 11 | "lint-fix", 12 | "pretty-fix-echo", 13 | "pretty-fix" 14 | ], 15 | "dependencies": { 16 | "discord.js": "^14.15.0", 17 | "dotenv": "^16.0.3", 18 | "drizzle-orm": "^0.29.3", 19 | "figlet": "^1.7.0", 20 | "pretty-seconds": "^3.0.1", 21 | "typescript": "^5.0.4" 22 | }, 23 | "scripts": { 24 | "clean": "rm -rf ./dist && find src -type d -name 'dist' -exec rm -r {} +", 25 | "clean-all": "bun clean && rm -rf ./node_modules ./bun.lockb", 26 | "lint": "bun eslint './src/**/*.ts'", 27 | "pretty": "bun prettier -c './src/**/*.ts'", 28 | "lint-fix": "bun eslint './src/**/*.ts' --fix", 29 | "lint-fix-echo": "echo 'Running lint-fix...'", 30 | "pretty-fix": "bun prettier -w './src/**/*.ts'", 31 | "pretty-fix-echo": "echo 'Running pretty-fix...'", 32 | "compile": "bun tsc", 33 | "start": "bun clean && bun compile && bun run dist/app.js", 34 | "start-container": "bun clean && docker compose --file dev-docker-compose.yaml up --build", 35 | "watch": "bun --watch run src/app.ts" 36 | }, 37 | "devDependencies": { 38 | "@types/bun": "latest", 39 | "@types/figlet": "^1.5.8", 40 | "@types/node": "^18.15.11", 41 | "@typescript-eslint/eslint-plugin": "^5.58.0", 42 | "@typescript-eslint/parser": "^5.4.0", 43 | "drizzle-kit": "^0.20.9", 44 | "eslint": "^8.38.0", 45 | "eslint-plugin-import": "^2.27.5", 46 | "pre-commit": "^1.2.2", 47 | "prettier": "^2.8.7" 48 | }, 49 | "description": "A simple-to-selfhost Discord bot which gives users the ability to have their server messages automatically deleted if they are older than a configurable TTL (time to live)", 50 | "peerDependencies": { 51 | "typescript": "^5.0.0" 52 | } 53 | } -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # ====================== 4 | # TERMINAL COLORS~ 5 | # ====================== 6 | MOVE_UP=`tput cuu 1` 7 | CLEAR_LINE=`tput el 1` 8 | BOLD=`tput bold` 9 | UNDERLINE=`tput smul` 10 | RED_TEXT=`tput setaf 1` 11 | GREEN_TEXT=`tput setaf 2` 12 | YELLOW_TEXT=`tput setaf 3` 13 | BLUE_TEXT=`tput setaf 4` 14 | MAGENTA_TEXT=`tput setaf 5` 15 | CYAN_TEXT=`tput setaf 6` 16 | WHITE_TEXT=`tput setaf 7` 17 | RESET=`tput sgr0` 18 | 19 | CURRENT_DIR=$(pwd) 20 | # source: https://stackoverflow.com/questions/59895/how-do-i-get-the-directory-where-a-bash-script-is-located-from-within-the-script 21 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 22 | 23 | # ====================================================== 24 | # FLAGS LOGIC 25 | # Source: https://www.banjocode.com/post/bash/flags-bash 26 | # ====================================================== 27 | SKIP_DOCKER=false 28 | SKIP_DOCKER_COMPOSE=false 29 | SKIP_AUTO_UPDATER=false 30 | while [ "$1" != "" ]; do 31 | case $1 in 32 | --skip-docker) 33 | SKIP_DOCKER=true 34 | ;; 35 | --skip-auto-updater) 36 | SKIP_AUTO_UPDATER=true 37 | ;; 38 | esac 39 | shift # remove the current value for `$1` and use the next 40 | done 41 | 42 | # Ensure the .env exists 43 | if [[ ! -f ".env" ]]; then 44 | NEW_DOTENV=true 45 | cp .env.example .env 46 | fi 47 | 48 | # ======================================= 49 | # OS CHECK & HOMEBREW INSTALL (for MacOS) 50 | # ======================================= 51 | ENV="Linux" 52 | if [[ "$OSTYPE" != "linux-gnu"* && "$OSTYPE" != "darwin"* ]]; then 53 | echo "${RESET}${RED_TEXT}[${BOLD}ERROR${RESET}${RED_TEXT}]${RESET}${BOLD}${YELLOW_TEXT} It looks like you are trying to run this script on a non-unix environment.${RESET}" 54 | echo " ${YELLOW_TEXT}Please note that this script is ${UNDERLINE}only${RESET}${YELLOW_TEXT} designed for use on Ubuntu/MacOS.${RESET}" 55 | return 1 56 | elif [[ "$OSTYPE" == "darwin"* ]]; then 57 | ENV="MacOS" 58 | # Make sure that Homebrew is installed / updated (used for docker/docker compose installations on mac) 59 | if [[ $(command -v brew) == "" ]]; then 60 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 61 | else 62 | brew update 63 | fi 64 | fi 65 | echo "${RESET}${YELLOW_TEXT}[${BOLD}OS Check${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} It looks like you're running this script on ${ENV}! Nice :)" 66 | 67 | # ====================================================== 68 | # DOCKER INSTALL 69 | # Source: https://docs.docker.com/engine/install/ubuntu/ 70 | # ====================================================== 71 | if docker version &>/dev/null && docker compose version &>/dev/null; then 72 | echo "${RESET}${YELLOW_TEXT}(${ENV}) [${BOLD}Docker Setup${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Docker installation detected${RESET}" 73 | SKIP_DOCKER=true 74 | fi 75 | if [[ $SKIP_DOCKER == true ]]; then 76 | echo "${RESET}${YELLOW_TEXT}(${ENV}) [${BOLD}Docker Setup${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Skipping Docker installation!${RESET}" 77 | else 78 | if [[ $ENV == "Linux" ]]; then 79 | # Remove any old files 80 | echo "${RESET}${YELLOW_TEXT}(${ENV}) [${BOLD}Docker Setup${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Preparing for Docker install...${RESET}" 81 | sudo apt-get remove docker docker-engine docker.io containerd runc -y 82 | # Stable repository setup 83 | sudo apt-get update -y 84 | sudo apt autoremove -y 85 | sudo apt-get install \ 86 | ca-certificates \ 87 | curl \ 88 | gnupg \ 89 | lsb-release -y 90 | curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --yes --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg 91 | echo \ 92 | "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu \ 93 | $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null 94 | fi 95 | echo "${RESET}${YELLOW_TEXT}(${ENV}) [${BOLD}Docker Setup${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Installing Docker...${RESET}" 96 | if [[ $ENV == "MacOS" ]]; then 97 | brew uninstall --cask docker --force &>/dev/null 98 | brew install --cask docker --force 99 | else 100 | # Install Docker Engine 101 | sudo apt-get update -y 102 | sudo apt-get install docker-ce docker-ce-cli containerd.io -y 103 | fi 104 | echo "${RESET}${YELLOW_TEXT}(${ENV}) [${BOLD}Docker Setup${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${GREEN_TEXT} Done!${RESET}" 105 | fi 106 | 107 | # =================== 108 | # AUTO-UPDATER CRON 109 | # =================== 110 | # This just adds an entry to the crontab to run update-ttl.sh at a regular interval 111 | if [[ $SKIP_AUTO_UPDATER == true ]]; then 112 | echo "${RESET}${YELLOW_TEXT}(${ENV}) [${BOLD}Auto-Updater Setup${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${BLUE_TEXT} Skipping Auto-Updater setup!${RESET}" 113 | else 114 | # source: https://stackoverflow.com/questions/59895/how-do-i-get-the-directory-where-a-bash-script-is-located-from-within-the-script 115 | SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) 116 | # Remove any existing entry in the crontab 117 | crontab -l | grep -v '/bin/update-ttl.sh' | crontab - &>/dev/null 118 | # Add update.sh to crontab 119 | (crontab -l 2>/dev/null; echo "10 */6 * * * ${SCRIPT_DIR}/bin/update-ttl.sh") | crontab - 120 | echo "${RESET}${YELLOW_TEXT}(${ENV}) [${BOLD}Auto-Updater Setup${RESET}${YELLOW_TEXT}]${RESET}${BOLD}${GREEN_TEXT} Added update-ttl.sh to crontab${RESET}" 121 | fi 122 | 123 | # Actually startup TTL 124 | docker compose up -d 125 | 126 | # Return to the directory we initially ran the script from 127 | cd $CURRENT_DIR 128 | 129 | # ================ 130 | # FINAL PRINT 131 | # ================ 132 | echo "" 133 | echo "${RESET}${BOLD}${GREEN_TEXT} Automated setup complete!${RESET}" 134 | echo "${RESET}${BOLD}${GREEN_TEXT} ૮ ˶ᵔ ᵕ ᵔ˶ ა${RESET}" 135 | echo "" 136 | if [[ $NEW_DOTENV == true ]]; then 137 | echo "${RESET}${YELLOW_TEXT} Be sure to place a Discord bot token in the${RESET}" 138 | echo "${RESET}${YELLOW_TEXT} .env file where it says ${BOLD}DISCORD_BOT_TOKEN=${RESET}" 139 | echo "${RESET}${YELLOW_TEXT} ${UNDERLINE}before${RESET}${YELLOW_TEXT} starting the bot. You can do this${RESET}" 140 | echo "${RESET}${YELLOW_TEXT} via the terminal with your preferred text${RESET}" 141 | echo "${RESET}${YELLOW_TEXT} editor (i.e. nano, vim, etc.).${RESET}" 142 | echo "" 143 | echo "${RESET}${YELLOW_TEXT} If you do not currently have a Discord bot token${RESET}" 144 | echo "${RESET}${YELLOW_TEXT} and you don't know how to get one, Discord.JS has a${RESET}" 145 | echo "${RESET}${YELLOW_TEXT} really helpful tutorial for how to do such here:${RESET}" 146 | echo "${RESET}${BLUE_TEXT} ${UNDERLINE}https://ayu.dev/r/discord-bot-token-guide${RESET}" 147 | echo "" 148 | fi 149 | echo "${RESET}${BOLD}${CYAN_TEXT} If TTL goes offline, you can start it by typing${RESET}${CYAN_TEXT}:${RESET}" 150 | echo "" 151 | echo "${RESET}${WHITE_TEXT} docker compose up -d${RESET}" 152 | echo "" 153 | echo "${RESET}${BOLD}${CYAN_TEXT} To check the logs, type${RESET}${CYAN_TEXT}:${RESET}" 154 | echo "" 155 | echo "${RESET}${WHITE_TEXT} docker compose logs -f${RESET}" 156 | echo "" 157 | if [[ $ENV == "MacOS" && $SKIP_DOCKER == false ]]; then 158 | echo "${RESET}${YELLOW_TEXT} Since you are on Mac OS, you may need to start the${RESET}" 159 | echo "${RESET}${YELLOW_TEXT} Docker app first via Cmd + Space -> Typing \"Docker\"${RESET}" 160 | echo "" 161 | fi 162 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import figlet from 'figlet'; 3 | import { loginToDiscordAndBeginDeleting } from './bot/api'; 4 | import { info, printStartupMessage } from './logger'; 5 | dotenv.config(); 6 | 7 | console.log('\x1b[36m' + figlet.textSync('Discord TTL') + '\x1b[0m'); 8 | console.log('\x1b[90m https://github.com/ayubun/discord-ttl\x1b[0m'); 9 | console.log(''); 10 | 11 | printStartupMessage(); 12 | info('Starting up...'); 13 | 14 | loginToDiscordAndBeginDeleting(); 15 | -------------------------------------------------------------------------------- /src/bot/api.ts: -------------------------------------------------------------------------------- 1 | import { Partials } from 'discord.js'; 2 | import { error, info } from '../logger'; 3 | import { continuallyRetrieveAndDeleteMessages } from './core'; 4 | import { CookieClient } from './cookie'; 5 | 6 | function getToken(): string { 7 | const token = process.env['DISCORD_BOT_TOKEN']; 8 | if (!token) { 9 | error('Discord token was not provided in the .env (i.e. DISCORD_BOT_TOKEN=token)'); 10 | error('To get a token, see: https://ayu.dev/r/discord-bot-token-guide'); 11 | error('Then, paste it into the .env file in the discord-ttl directory and restart.'); 12 | process.exit(1); 13 | } 14 | return token; 15 | } 16 | 17 | export const bot = new CookieClient({ 18 | intents: ['Guilds'], 19 | partials: [Partials.Channel, Partials.Message], 20 | }); 21 | 22 | export function loginToDiscordAndBeginDeleting() { 23 | bot.once('ready', () => { 24 | info('Logged in to Discord and now continually retrieving messages for deletion!'); 25 | continuallyRetrieveAndDeleteMessages().catch((err: any) => { 26 | error('Encountered a fatal error in the core loop:', err); 27 | process.exit(1); 28 | }); 29 | }); 30 | 31 | bot.login(getToken()).catch((err: any) => { 32 | error('Encountered a fatal error while logging in:', err); 33 | process.exit(1); 34 | }); 35 | } 36 | -------------------------------------------------------------------------------- /src/bot/commands/default-ttl/reset/current-channel.ts: -------------------------------------------------------------------------------- 1 | import { PermissionFlagsBits, ChatInputCommandInteraction } from 'discord.js'; 2 | import { getServerChannelSettings, setServerChannelSettings } from '../../../../database/api'; 3 | import { ServerChannelSettings } from '../../../../common/types'; 4 | import { getServerSettingsDiff } from '../../../common/utils'; 5 | import { CookieCommand, CookieConfirmationMenu } from '../../../cookie'; 6 | 7 | const data = { 8 | default_member_permissions: String(PermissionFlagsBits.ManageGuild), 9 | description: 'Reset the default message TTL settings for this channel', 10 | }; 11 | 12 | const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 13 | const currentSettings: ServerChannelSettings = await getServerChannelSettings( 14 | interaction.guildId!, 15 | interaction.channelId, 16 | ); 17 | const defaultSettings = new ServerChannelSettings(interaction.guildId!, interaction.channelId); 18 | 19 | if (currentSettings === defaultSettings) { 20 | return await interaction.reply({ 21 | embeds: [ 22 | { 23 | description: 'This channel already has default TTL settings ^-^', 24 | }, 25 | ], 26 | ephemeral: true, 27 | }); 28 | } 29 | 30 | const result = await new CookieConfirmationMenu(self, interaction) 31 | .setPromptMessage( 32 | 'Are you sure you want to reset the default TTL settings for this channel?\n' + 33 | getServerSettingsDiff(currentSettings, defaultSettings), 34 | ) 35 | .setSuccessMessage( 36 | 'The default TTL settings for this channel have been reset to defaults~\n' + 37 | getServerSettingsDiff(currentSettings, defaultSettings), 38 | ) 39 | .prompt(); 40 | 41 | if (result.isConfirmed()) { 42 | try { 43 | await setServerChannelSettings(defaultSettings); 44 | } catch (error) { 45 | return await result.error(String(error)); 46 | } 47 | } 48 | await result.update(); 49 | }; 50 | 51 | export { data, onExecute }; 52 | -------------------------------------------------------------------------------- /src/bot/commands/default-ttl/reset/server-wide.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType, ChatInputCommandInteraction, PermissionFlagsBits } from 'discord.js'; 2 | import { ServerSettings } from '../../../../common/types'; 3 | import { getServerSettings, resetAllServerSettings, setServerSettings } from '../../../../database/api'; 4 | import { getServerSettingsDiff } from '../../../common/utils'; 5 | import { CookieCommand, CookieConfirmationMenu } from '../../../cookie'; 6 | 7 | const data = { 8 | default_member_permissions: String(PermissionFlagsBits.ManageGuild), 9 | description: 'Unset the default message TTL (time to live) for everyone in this server or channel', 10 | options: [ 11 | { 12 | type: ApplicationCommandOptionType.Boolean, 13 | name: 'also-reset-all-channels', 14 | description: 'Set to "True" to also unset ALL channel settings for this server.', 15 | }, 16 | ], 17 | }; 18 | 19 | const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 20 | const clearAllChannels = interaction.options.getBoolean('also-reset-all-channels', false) ? true : false; 21 | const currentSettings: ServerSettings = await getServerSettings(interaction.guildId!); 22 | const defaultSettings = new ServerSettings(interaction.guildId!); 23 | 24 | if (currentSettings === defaultSettings && !clearAllChannels) { 25 | return await interaction.reply({ 26 | embeds: [ 27 | { 28 | description: 'The server already has default TTL settings ^-^', 29 | }, 30 | ], 31 | ephemeral: true, 32 | }); 33 | } 34 | 35 | const dangerMsg = clearAllChannels 36 | ? '**__DANGER__**: You have selected to reset ***all*** channel settings to default.\n' 37 | : ''; 38 | const extraSuccessMsg = clearAllChannels ? 'All channel settings have also been reset to default.\n' : ''; 39 | const result = await new CookieConfirmationMenu(self, interaction) 40 | .setPromptMessage( 41 | 'Are you sure you want to reset the default TTL settings for this server?\n' + 42 | dangerMsg + 43 | '\n' + 44 | getServerSettingsDiff(currentSettings, defaultSettings), 45 | ) 46 | .setSuccessMessage( 47 | 'The default TTL settings for this server have been reset to defaults~\n' + 48 | extraSuccessMsg + 49 | '\n' + 50 | getServerSettingsDiff(currentSettings, defaultSettings), 51 | ) 52 | .prompt(); 53 | 54 | if (result.isConfirmed()) { 55 | try { 56 | if (clearAllChannels) { 57 | await resetAllServerSettings(interaction.guildId!); 58 | } 59 | await setServerSettings(defaultSettings); 60 | } catch (error) { 61 | return await result.error(String(error)); 62 | } 63 | } 64 | await result.update(); 65 | }; 66 | 67 | export { data, onExecute }; 68 | -------------------------------------------------------------------------------- /src/bot/commands/default-ttl/set/current-channel.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType, ChatInputCommandInteraction, PermissionFlagsBits } from 'discord.js'; 2 | import { FOREVER_TTL } from '../../../../common/types'; 3 | import { getServerChannelSettings, setServerChannelSettings } from '../../../../database/api'; 4 | import { CookieCommand, CookieConfirmationMenu } from '../../../cookie'; 5 | import { 6 | getSecondsFromTimeString, 7 | getServerSettingsDiff, 8 | isForeverTtlString, 9 | isResetString, 10 | } from '../../../common/utils'; 11 | 12 | const data = { 13 | default_member_permissions: String(PermissionFlagsBits.ManageGuild), 14 | description: 'Sets the default TTL settings for everyone in this channel', 15 | options: [ 16 | { 17 | type: ApplicationCommandOptionType.String, 18 | name: 'default-time', 19 | description: 'Default message TTL (e.g. `1h10m`, `1 week`). "forever" = No TTL. "reset" = Reset to default', 20 | required: true, 21 | }, 22 | // { 23 | // type: ApplicationCommandOptionType.String, 24 | // name: 'max-time', 25 | // description: 'Max message TTL (e.g. `1h10m`, `30 min`, `1 week`). "forever" = No max. "reset" = Reset to default', 26 | // required: false, 27 | // }, 28 | // { 29 | // type: ApplicationCommandOptionType.String, 30 | // name: 'min-time', 31 | // description: 32 | // 'Min message TTL (e.g. `1h10m`, `30 min`, `1 week`). "forever" = TTL cannot be used. "reset" = Reset to default', 33 | // required: false, 34 | // }, 35 | { 36 | type: ApplicationCommandOptionType.Boolean, 37 | name: 'include-pins-by-default', 38 | description: 'Whether to include pins by default. "default" = Reset to default', 39 | required: false, 40 | }, 41 | ], 42 | }; 43 | 44 | const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 45 | const defaultTimeString = interaction.options.getString('default-time', true).toLocaleLowerCase(); 46 | const maxTimeString = interaction.options.getString('max-time', false)?.toLocaleLowerCase(); 47 | const minTimeString = interaction.options.getString('min-time', false)?.toLocaleLowerCase(); 48 | const includePinsDefault = interaction.options.getBoolean('include-pins-by-default', false); 49 | 50 | const defaultTtlSeconds = 51 | defaultTimeString && !isForeverTtlString(defaultTimeString) 52 | ? getSecondsFromTimeString(defaultTimeString) 53 | : undefined; 54 | const maxTtlSeconds = 55 | maxTimeString && !isForeverTtlString(maxTimeString) ? getSecondsFromTimeString(maxTimeString) : undefined; 56 | const minTtlSeconds = 57 | minTimeString && !isForeverTtlString(minTimeString) ? getSecondsFromTimeString(minTimeString) : undefined; 58 | 59 | const currentSettings = await getServerChannelSettings(interaction.guildId!, interaction.channelId); 60 | const newSettings = currentSettings.clone(); 61 | 62 | if (isResetString(defaultTimeString)) { 63 | newSettings.defaultMessageTtl = null; 64 | } else if (isForeverTtlString(defaultTimeString)) { 65 | newSettings.defaultMessageTtl = FOREVER_TTL; 66 | } else if (defaultTtlSeconds) { 67 | newSettings.defaultMessageTtl = defaultTtlSeconds; 68 | } 69 | 70 | if (isResetString(maxTimeString)) { 71 | newSettings.maxMessageTtl = null; 72 | } else if (isForeverTtlString(maxTimeString)) { 73 | newSettings.maxMessageTtl = FOREVER_TTL; 74 | } else if (maxTtlSeconds) { 75 | newSettings.maxMessageTtl = maxTtlSeconds; 76 | } 77 | 78 | if (isResetString(minTimeString)) { 79 | newSettings.minMessageTtl = null; 80 | } else if (isForeverTtlString(minTimeString)) { 81 | newSettings.minMessageTtl = FOREVER_TTL; 82 | } else if (minTtlSeconds) { 83 | newSettings.minMessageTtl = minTtlSeconds; 84 | } 85 | 86 | if (includePinsDefault) { 87 | newSettings.includePinsByDefault = includePinsDefault; 88 | } 89 | 90 | if (currentSettings === newSettings) { 91 | return await interaction.reply({ 92 | embeds: [ 93 | { 94 | description: 'This channel already matches the provided settings ^-^', 95 | }, 96 | ], 97 | ephemeral: true, 98 | }); 99 | } 100 | 101 | const result = await new CookieConfirmationMenu(self, interaction) 102 | .setPromptMessage( 103 | 'Are you sure you want to update the default TTL settings for this channel?\n' + 104 | getServerSettingsDiff(currentSettings, newSettings), 105 | ) 106 | .setSuccessMessage( 107 | 'The default TTL settings for this channel have been updated~\n' + 108 | getServerSettingsDiff(currentSettings, newSettings), 109 | ) 110 | .prompt(); 111 | 112 | if (result.isConfirmed()) { 113 | try { 114 | await setServerChannelSettings(newSettings); 115 | } catch (error) { 116 | return await result.error(String(error)); 117 | } 118 | } 119 | await result.update(); 120 | }; 121 | 122 | export { data, onExecute }; 123 | -------------------------------------------------------------------------------- /src/bot/commands/default-ttl/set/server-wide.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationCommandOptionType, ChatInputCommandInteraction, PermissionFlagsBits } from 'discord.js'; 2 | import { FOREVER_TTL } from '../../../../common/types'; 3 | import { getServerSettings, setServerSettings } from '../../../../database/api'; 4 | import { CookieCommand, CookieConfirmationMenu } from '../../../cookie'; 5 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 6 | import { 7 | getSecondsFromTimeString, 8 | getServerSettingsDiff, 9 | isForeverTtlString, 10 | isResetString, 11 | } from '../../../common/utils'; 12 | 13 | const data = { 14 | default_member_permissions: String(PermissionFlagsBits.ManageGuild), 15 | description: 'Sets the default TTL settings for everyone in this server', 16 | options: [ 17 | { 18 | type: ApplicationCommandOptionType.String, 19 | name: 'default-time', 20 | description: 'Default message TTL (e.g. `1h10m`, `1 week`). "forever" = No TTL. "reset" = Reset to default', 21 | required: true, 22 | }, 23 | // { 24 | // type: ApplicationCommandOptionType.String, 25 | // name: 'max-time', 26 | // description: 'Max user TTL (e.g. `1h10m`, `30 min`, `1 week`). "forever" = No max. "reset" = Reset to default', 27 | // required: false, 28 | // }, 29 | // { 30 | // type: ApplicationCommandOptionType.String, 31 | // name: 'min-time', 32 | // description: 33 | // 'Min user TTL (e.g. `1h10m`, `30 min`, `1 week`). "forever" = TTL cannot be used. "reset" = Reset to default', 34 | // required: false, 35 | // }, 36 | { 37 | type: ApplicationCommandOptionType.Boolean, 38 | name: 'include-pins-by-default', 39 | description: 'Whether to include pins by default. "default" = Reset to default', 40 | required: false, 41 | }, 42 | ], 43 | }; 44 | 45 | const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 46 | const defaultTimeString = interaction.options.getString('default-time', true).toLocaleLowerCase(); 47 | const maxTimeString = interaction.options.getString('max-time', false)?.toLocaleLowerCase(); 48 | const minTimeString = interaction.options.getString('min-time', false)?.toLocaleLowerCase(); 49 | const includePinsDefault = interaction.options.getBoolean('include-pins-by-default', false); 50 | 51 | const defaultTtlSeconds = 52 | defaultTimeString && !isForeverTtlString(defaultTimeString) 53 | ? getSecondsFromTimeString(defaultTimeString) 54 | : undefined; 55 | const maxTtlSeconds = 56 | maxTimeString && !isForeverTtlString(maxTimeString) ? getSecondsFromTimeString(maxTimeString) : undefined; 57 | const minTtlSeconds = 58 | minTimeString && !isForeverTtlString(minTimeString) ? getSecondsFromTimeString(minTimeString) : undefined; 59 | 60 | const currentSettings = await getServerSettings(interaction.guildId!); 61 | const newSettings = currentSettings.clone(); 62 | 63 | if (isResetString(defaultTimeString)) { 64 | newSettings.defaultMessageTtl = null; 65 | } else if (isForeverTtlString(defaultTimeString)) { 66 | newSettings.defaultMessageTtl = FOREVER_TTL; 67 | } else if (defaultTtlSeconds) { 68 | newSettings.defaultMessageTtl = defaultTtlSeconds; 69 | } 70 | 71 | if (isResetString(maxTimeString)) { 72 | newSettings.maxMessageTtl = null; 73 | } else if (isForeverTtlString(maxTimeString)) { 74 | newSettings.maxMessageTtl = FOREVER_TTL; 75 | } else if (maxTtlSeconds) { 76 | newSettings.maxMessageTtl = maxTtlSeconds; 77 | } 78 | 79 | if (isResetString(minTimeString)) { 80 | newSettings.minMessageTtl = null; 81 | } else if (isForeverTtlString(minTimeString)) { 82 | newSettings.minMessageTtl = FOREVER_TTL; 83 | } else if (minTtlSeconds) { 84 | newSettings.minMessageTtl = minTtlSeconds; 85 | } 86 | 87 | if (includePinsDefault) { 88 | newSettings.includePinsByDefault = includePinsDefault; 89 | } 90 | 91 | if (currentSettings === newSettings) { 92 | return await interaction.reply({ 93 | embeds: [ 94 | { 95 | description: 'This server already matches the provided settings ^-^', 96 | }, 97 | ], 98 | ephemeral: true, 99 | }); 100 | } 101 | 102 | const result = await new CookieConfirmationMenu(self, interaction) 103 | .setPromptMessage( 104 | 'Are you sure you want to update the default TTL settings for this server?\n' + 105 | getServerSettingsDiff(currentSettings, newSettings), 106 | ) 107 | .setSuccessMessage( 108 | 'The default TTL settings for this server have been updated~\n' + 109 | getServerSettingsDiff(currentSettings, newSettings), 110 | ) 111 | .prompt(); 112 | 113 | if (result.isConfirmed()) { 114 | try { 115 | await setServerSettings(newSettings); 116 | } catch (error) { 117 | return await result.error(String(error)); 118 | } 119 | } 120 | await result.update(); 121 | }; 122 | 123 | export { data, onExecute }; 124 | -------------------------------------------------------------------------------- /src/bot/commands/my-ttl/reset/current-channel.ts: -------------------------------------------------------------------------------- 1 | // import { ChatInputCommandInteraction } from 'discord.js'; 2 | // import { CookieCommand } from '../../../cookie'; 3 | 4 | // const data = { 5 | // description: 'Reset your TTL settings for this channel', 6 | // }; 7 | 8 | // const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 9 | // return await interaction.reply({ 10 | // content: `The ${self.getMention()} command is pending implementation`, 11 | // ephemeral: true, 12 | // }); 13 | // }; 14 | 15 | // export { data, onExecute }; 16 | -------------------------------------------------------------------------------- /src/bot/commands/my-ttl/reset/server-wide.ts: -------------------------------------------------------------------------------- 1 | // import { ApplicationCommandOptionType, ChatInputCommandInteraction } from 'discord.js'; 2 | // import { CookieCommand } from '../../../cookie'; 3 | 4 | // const data = { 5 | // description: 'Reset your TTL settings for this server', 6 | // options: [ 7 | // { 8 | // type: ApplicationCommandOptionType.Boolean, 9 | // name: 'also-reset-all-channels', 10 | // description: 'Set to "True" to also unset ALL channel settings for this server.', 11 | // }, 12 | // ], 13 | // }; 14 | 15 | // const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 16 | // await interaction.reply({ 17 | // content: `The ${self.getMention()} command is pending implementation`, 18 | // ephemeral: true, 19 | // }); 20 | // }; 21 | 22 | // export { data, onExecute }; 23 | -------------------------------------------------------------------------------- /src/bot/commands/my-ttl/set/current-channel.ts: -------------------------------------------------------------------------------- 1 | // import { ApplicationCommandOptionType, ChatInputCommandInteraction } from 'discord.js'; 2 | // import { CookieCommand } from '../../../cookie'; 3 | 4 | // const data = { 5 | // description: 'Set your TTL settings for this channel', 6 | // options: [ 7 | // { 8 | // type: ApplicationCommandOptionType.String, 9 | // name: 'time', 10 | // description: 'Message TTL (e.g. "1h10m", "30 min", "1 week"). Put "forever" to never apply TTL to your messages', 11 | // required: true, 12 | // }, 13 | // { 14 | // type: ApplicationCommandOptionType.Boolean, 15 | // name: 'include-pins', 16 | // description: 'Specify whether this message TTL should include pinned messages. Defaults to "False"', 17 | // }, 18 | // ], 19 | // }; 20 | 21 | // const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 22 | // return await interaction.reply({ 23 | // content: `The ${self.getMention()} command is pending implementation`, 24 | // ephemeral: true, 25 | // }); 26 | // }; 27 | 28 | // export { data, onExecute }; 29 | -------------------------------------------------------------------------------- /src/bot/commands/my-ttl/set/server-wide.ts: -------------------------------------------------------------------------------- 1 | // import { ApplicationCommandOptionType, ChatInputCommandInteraction } from 'discord.js'; 2 | // import { CookieCommand } from '../../../cookie'; 3 | 4 | // const data = { 5 | // description: 'Set your TTL settings for this server', 6 | // options: [ 7 | // { 8 | // type: ApplicationCommandOptionType.String, 9 | // name: 'time', 10 | // description: 'Message TTL (e.g. "1h10m", "30 min", "1 week"). Put "forever" to never apply TTL to your messages', 11 | // required: true, 12 | // }, 13 | // { 14 | // type: ApplicationCommandOptionType.Boolean, 15 | // name: 'include-pins', 16 | // description: 'Specify whether this message TTL should include pinned messages. Defaults to "False"', 17 | // }, 18 | // ], 19 | // }; 20 | 21 | // const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 22 | // return await interaction.reply({ 23 | // content: `The ${self.getMention()} command is pending implementation`, 24 | // ephemeral: true, 25 | // }); 26 | // }; 27 | 28 | // export { data, onExecute }; 29 | -------------------------------------------------------------------------------- /src/bot/commands/ttl.ts: -------------------------------------------------------------------------------- 1 | import { ChatInputCommandInteraction, PermissionFlagsBits } from 'discord.js'; 2 | import { getServerChannelSettings, getServerSettings } from '../../database/api'; 3 | import { CookieCommand } from '../cookie'; 4 | import { getServerSettingsDisplay } from '../common/utils'; 5 | 6 | const data = { 7 | default_member_permissions: String(PermissionFlagsBits.SendMessages), 8 | description: 'Get the TTL settings for the current scope', 9 | }; 10 | 11 | const onExecute = async (self: CookieCommand, interaction: ChatInputCommandInteraction) => { 12 | const serverSettings = await getServerSettings(interaction.guildId!); 13 | const channelSettings = await getServerChannelSettings(interaction.guildId!, interaction.channelId); 14 | const effectiveSettings = channelSettings.applyServerSettings(serverSettings); 15 | await interaction.reply({ 16 | embeds: [ 17 | { 18 | title: 'Current TTL Settings', 19 | description: 20 | getServerSettingsDisplay(serverSettings, '### __Server Settings__') + 21 | '\n' + 22 | getServerSettingsDisplay(channelSettings, '### __Channel Settings__') + 23 | '\n' + 24 | getServerSettingsDisplay(effectiveSettings, '### __Effective Settings__'), 25 | }, 26 | ], 27 | ephemeral: true, 28 | }); 29 | }; 30 | 31 | export { data, onExecute }; 32 | -------------------------------------------------------------------------------- /src/bot/common/utils.ts: -------------------------------------------------------------------------------- 1 | import { FOREVER_TTL, type ServerChannelSettings, type ServerSettings } from '../../common/types'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-var-requires 4 | const prettySeconds = require('pretty-seconds'); 5 | 6 | export const isResetString = (input: string | undefined): boolean => { 7 | switch (input) { 8 | case 'default': 9 | case 'reset': 10 | return true; 11 | } 12 | return false; 13 | }; 14 | 15 | export const isForeverTtlString = (duration: number | string | undefined): boolean => { 16 | if (duration === undefined) { 17 | return false; 18 | } 19 | if (typeof duration === 'number') { 20 | return duration <= 0 || duration === FOREVER_TTL; 21 | } 22 | switch (duration) { 23 | case 'forever': 24 | case '0': 25 | case '-1': 26 | case 'none': 27 | case 'no': 28 | case 'unset': 29 | case 'null': 30 | case 'off': 31 | case 'disable': 32 | case 'disabled': 33 | case String(FOREVER_TTL): 34 | return true; 35 | } 36 | return false; 37 | }; 38 | 39 | /** 40 | * Gets the seconds that a given duration string represents. 41 | * For example, the duration string `1h 30min 10s` would return `5410`. 42 | * If the string does not have any parsable durations, this function returns `undefined`. 43 | */ 44 | export const getSecondsFromTimeString = (duration: string): number | undefined => { 45 | duration = duration 46 | .toLowerCase() 47 | .replaceAll(/(and|,)/g, '') 48 | .replaceAll(/\s/g, ''); 49 | const secondsPerUnit = (unit: string): number | undefined => { 50 | switch (unit) { 51 | case 'seconds': 52 | case 'second': 53 | case 'secs': 54 | case 'sec': 55 | case 's': 56 | return 1; 57 | case 'minutes': 58 | case 'minute': 59 | case 'mins': 60 | case 'min': 61 | case 'm': 62 | return 60; 63 | case 'hours': 64 | case 'hour': 65 | case 'hrs': 66 | case 'hr': 67 | case 'h': 68 | return 3600; 69 | case 'days': 70 | case 'day': 71 | case 'd': 72 | return 86400; 73 | case 'weeks': 74 | case 'week': 75 | case 'w': 76 | return 604800; 77 | case 'months': 78 | case 'month': 79 | return 2592000; 80 | } 81 | return undefined; 82 | }; 83 | 84 | const splitString = (input: string): string[] => { 85 | return input.split(/(?<=\D)(?=\d)/g); 86 | }; 87 | 88 | let seconds = 0; 89 | splitString(duration).forEach((part: string) => { 90 | if (part === null || part === undefined) { 91 | return; 92 | } 93 | const unitMatcher = part.match(/[a-zA-Z]+/g); 94 | const numberMatcher = part.match(/[0-9]+/g); 95 | if (!unitMatcher || !numberMatcher) { 96 | return; 97 | } 98 | const unit = unitMatcher[0]; 99 | const number = numberMatcher[0]; 100 | const val = secondsPerUnit(unit); 101 | if (val) { 102 | seconds += parseInt(number, 10) * val; 103 | } 104 | }); 105 | if (seconds === 0) { 106 | return undefined; 107 | } 108 | return seconds; 109 | }; 110 | 111 | function getTtlDisplayString(friendlyTtl: number | undefined, rawTtl: number | undefined | null): string { 112 | let str = ''; 113 | if (friendlyTtl === undefined) { 114 | str += '`Forever`'; 115 | } else { 116 | str += '**' + String(prettySeconds(friendlyTtl)) + '**'; 117 | } 118 | if (rawTtl === undefined || rawTtl === null) { 119 | str += ' (*default*)'; 120 | } 121 | return str; 122 | } 123 | function getBooleanDisplayString(friendlyBoolean: boolean, rawBoolean: boolean | undefined | null): string { 124 | let str = '`' + String(friendlyBoolean) + '`'; 125 | if (rawBoolean === undefined || rawBoolean === null) { 126 | str += ' (*default*)'; 127 | } 128 | return str; 129 | } 130 | 131 | export const getServerSettingsDisplay = ( 132 | settings: ServerSettings | ServerChannelSettings, 133 | header = '### __Current Settings__', 134 | ): string => { 135 | let display = header + '\n'; 136 | display += '- __TTL__: ' + getTtlDisplayString(settings.getDefaultMessageTtl(), settings.defaultMessageTtl) + '\n'; 137 | // TODO: uncomment when user TTLs are implemented~ 138 | // display += ` - __User Minimum__: ${getTtlDisplayString(settings.getMinMessageTtl(), settings.minMessageTtl)}\n`; 139 | // display += ` - __User Maximum__: ${getTtlDisplayString(settings.getMaxMessageTtl(), settings.maxMessageTtl)}\n`; 140 | display += `- __Include Pins By Default__: ${getBooleanDisplayString( 141 | settings.getIncludePinsByDefault(), 142 | settings.includePinsByDefault, 143 | )}\n`; 144 | return display; 145 | }; 146 | 147 | export const getServerSettingsDiff = ( 148 | oldSettings: ServerSettings | ServerChannelSettings, 149 | newSettings: ServerSettings | ServerChannelSettings, 150 | header = '### __Settings Changes__', 151 | ): string => { 152 | let diff = header + '\n'; 153 | if (oldSettings === newSettings) { 154 | return diff + 'No changes are being made\n'; 155 | } 156 | if (oldSettings.defaultMessageTtl !== newSettings.defaultMessageTtl) { 157 | diff += `- __Default TTL__: ${getTtlDisplayString( 158 | oldSettings.getDefaultMessageTtl(), 159 | oldSettings.defaultMessageTtl, 160 | )} **→** ${getTtlDisplayString(newSettings.getDefaultMessageTtl(), newSettings.defaultMessageTtl)}\n`; 161 | } 162 | if (oldSettings.minMessageTtl !== newSettings.minMessageTtl) { 163 | diff += `- __User TTL Mininum__: ${getTtlDisplayString( 164 | oldSettings.getMinMessageTtl(), 165 | oldSettings.minMessageTtl, 166 | )} **→** ${getTtlDisplayString(oldSettings.getMinMessageTtl(), newSettings.minMessageTtl)}\n`; 167 | } 168 | if (oldSettings.maxMessageTtl !== newSettings.maxMessageTtl) { 169 | diff += `- __User TTL Maximum__: ${getTtlDisplayString( 170 | oldSettings.getMaxMessageTtl(), 171 | oldSettings.maxMessageTtl, 172 | )} **→** ${getTtlDisplayString(oldSettings.getMaxMessageTtl(), newSettings.maxMessageTtl)}\n`; 173 | } 174 | if (oldSettings.includePinsByDefault !== newSettings.includePinsByDefault) { 175 | diff += `- __Include Pins By Default__: ${getBooleanDisplayString( 176 | oldSettings.getIncludePinsByDefault(), 177 | oldSettings.includePinsByDefault, 178 | )} **→** ${getBooleanDisplayString(oldSettings.getIncludePinsByDefault(), newSettings.includePinsByDefault)}\n`; 179 | } 180 | return diff; 181 | }; 182 | -------------------------------------------------------------------------------- /src/bot/cookie.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * cookie.ts is a TypeScript file that dynamically handles parity between 3 | * compatible `command/` files and the Discord API. The cookie.ts 'API' provides 4 | * a `CookieClient` wrapper for the Discord.js `Client` class, which is 5 | * responsible for registering, routing, and executing application commands. 6 | */ 7 | import fs from 'node:fs'; 8 | import path from 'node:path'; 9 | import assert from 'node:assert'; 10 | import { 11 | ActionRowBuilder, 12 | ApplicationCommandOptionType, 13 | ApplicationCommandType, 14 | ButtonBuilder, 15 | ButtonInteraction, 16 | ButtonStyle, 17 | type CacheType, 18 | ChatInputCommandInteraction, 19 | Client, 20 | type ClientOptions, 21 | type CommandInteractionOption, 22 | Events, 23 | type Interaction, 24 | REST, 25 | Routes, 26 | } from 'discord.js'; 27 | 28 | /** 29 | * CookieClient is a Discord.js client abstraction that handles application commands :3 30 | */ 31 | export class CookieClient extends Client { 32 | private command_tree: Record; 33 | 34 | public constructor(options: ClientOptions) { 35 | super(options); 36 | // load all commands from the commands directory 37 | this.command_tree = CookieClient.buildCommandTree(); 38 | // listen for 'ready' event (which is sent when the bot connects to the discord api) because we 39 | // use some information returned from the api to register our commands (such as the client id) 40 | super.on('ready', () => { 41 | CookieLogger.info('Setting up application commands...'); 42 | this.deployCommands() 43 | .then(async data => { 44 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 45 | await this.populateCommandIds(data).catch(e => CookieLogger.error('Could not populate command ids:', e)); 46 | // this routes our interactions (a.k.a. application commands) to our own handler methods c: 47 | // https://stackoverflow.com/questions/63488141/promise-returned-in-function-argument-where-a-void-return-was-expected 48 | // eslint-disable-next-line @typescript-eslint/no-misused-promises 49 | super.on(Events.InteractionCreate, async interaction => await this.handleInteraction(interaction)); 50 | CookieLogger.info('The bot is now receiving & processing application commands'); 51 | }) 52 | .catch((err: any) => { 53 | CookieLogger.error('Encountered a fatal error while deploying commands:', err); 54 | process.exit(1); 55 | }); 56 | }); 57 | } 58 | 59 | /** 60 | * Returns a `command_tree` for the CookieClient based on files/folders within the `commands/` directory. 61 | * 62 | * The command tree will look something like this: 63 | * 'ttl': { 64 | * 'set': CookieCommand from File('./commands/ttl/set.ts'), 65 | * 'unset': CookieCommand from File('./commands/ttl/unset.ts'), 66 | * 'info': CookieCommand from File('./commands/ttl/info.ts'), 67 | * } 68 | */ 69 | private static buildCommandTree() { 70 | const root_commands_path = path.join(import.meta.dir, 'commands'); 71 | 72 | function mapCommandPathsToCookieCommandsRecursively(current_dir: string): Record { 73 | const command_tree: Record = {}; 74 | let errors = false; 75 | fs.readdirSync(current_dir).map(file_name => { 76 | const full_file_path = path.join(current_dir, file_name); 77 | if (fs.lstatSync(full_file_path).isDirectory()) { 78 | command_tree[file_name] = mapCommandPathsToCookieCommandsRecursively(full_file_path); 79 | } 80 | if (!file_name.endsWith('.js')) { 81 | return; 82 | } 83 | const cookie_command = CookieCommand.fromFile(full_file_path); 84 | if (!cookie_command) { 85 | // Since we want to print out all of the poorly formatted commands, we will continue for now 86 | errors = true; 87 | return; 88 | } 89 | command_tree[cookie_command.getName()] = cookie_command; 90 | }); 91 | if (errors) { 92 | // TODO: uncomment once user ttls are implemented ! 93 | // CookieLogger.error( 94 | // 'This should not happen in production. Create an Issue on GitHub if you are seeing this during normal bot usage.', 95 | // ); 96 | // process.exit(1); 97 | } 98 | return command_tree; 99 | } 100 | 101 | return mapCommandPathsToCookieCommandsRecursively(root_commands_path); 102 | } 103 | 104 | /** 105 | * Deploys the application commands to the Discord API. 106 | * https://discord.com/developers/docs/interactions/application-commands#example-walkthrough 107 | */ 108 | private async deployCommands(): Promise { 109 | assert(this.token, 'Invariant: Missing valid token'); 110 | assert(this.user?.id, 'Invariant: Missing valid client id'); 111 | 112 | /** 113 | * @returns the minimum root permissions necessary to run the least restrictive subcommand 114 | */ 115 | const getMinimumNecessaryPermissions = (current_command_tree: Record): bigint => { 116 | if (CookieCommand.isCookieCommand(current_command_tree)) { 117 | const cmd = current_command_tree; 118 | const cmdPerms = cmd.getJsonData()['default_member_permissions']; 119 | if (cmdPerms && typeof cmdPerms === 'string') { 120 | return BigInt(cmdPerms); 121 | } 122 | } else { 123 | const keys = Object.keys(current_command_tree); 124 | if (!keys) { 125 | return BigInt(0); 126 | } 127 | let perms = BigInt('0xffffffff'); 128 | for (const key of keys) { 129 | perms &= getMinimumNecessaryPermissions(current_command_tree[key] as Record); 130 | } 131 | return perms; 132 | } 133 | return BigInt(0); 134 | }; 135 | 136 | const buildJsonDataFromTree = ( 137 | parent_key: string, 138 | current_command_tree: Record, 139 | current_depth: number = 0, 140 | root_permissions: bigint = BigInt(0), 141 | ): Record => { 142 | let new_json_data_tree: Record = { 143 | // We will autofill the command layer 144 | name: parent_key, 145 | // The API docs seem unclear on if this is necessary to populate 146 | // for subcommand groups / parent commands. This shouldn't be visible to users though 147 | description: 'bun', 148 | }; 149 | if (current_depth === 0) { 150 | const perms = getMinimumNecessaryPermissions(current_command_tree); 151 | if (perms !== BigInt(0)) { 152 | new_json_data_tree['default_member_permissions'] = String(perms); 153 | root_permissions = perms; 154 | } 155 | } 156 | if (CookieCommand.isCookieCommand(current_command_tree)) { 157 | const cmd = current_command_tree; 158 | switch (current_depth) { 159 | case 0: // Command layer 160 | new_json_data_tree['type'] = ApplicationCommandType.ChatInput; 161 | break; 162 | default: // Subcommand layer 163 | new_json_data_tree['type'] = ApplicationCommandOptionType.Subcommand; 164 | break; 165 | } 166 | new_json_data_tree = { ...new_json_data_tree, ...cmd.getJsonData() }; 167 | cmd.setRootPermissions(root_permissions); 168 | return new_json_data_tree; 169 | } else { 170 | switch (current_depth) { 171 | case 0: // Command layer 172 | new_json_data_tree['type'] = ApplicationCommandType.ChatInput; 173 | break; 174 | default: // Subcommand layer 175 | new_json_data_tree['type'] = ApplicationCommandOptionType.SubcommandGroup; 176 | break; 177 | } 178 | new_json_data_tree['options'] = []; 179 | for (const key of Object.keys(current_command_tree)) { 180 | new_json_data_tree['options'].push( 181 | buildJsonDataFromTree( 182 | key, 183 | current_command_tree[key] as Record, 184 | current_depth + 1, 185 | root_permissions, 186 | ), 187 | ); 188 | } 189 | } 190 | return new_json_data_tree; 191 | }; 192 | 193 | const commands: any[] = []; 194 | const commands_to_add = Object.keys(this.command_tree); 195 | 196 | while (commands_to_add.length > 0) { 197 | const next_command = commands_to_add.pop(); 198 | if (!next_command) { 199 | break; 200 | } 201 | commands.push(buildJsonDataFromTree(next_command, this.command_tree[next_command] as Record)); 202 | } 203 | 204 | const payload = { body: commands }; 205 | const rest = new REST().setToken(this.token); 206 | CookieLogger.debug( 207 | `Sending 'PUT ${Routes.applicationCommands(this.user.id)}' with the following payload:`, 208 | `${JSON.stringify(payload, undefined, 2)}`, 209 | ); 210 | // we await (instead of `.then().catch()`) so that the error will bubble up to the caller 211 | const data: any = await rest.put(Routes.applicationCommands(this.user.id), payload); 212 | assert( 213 | data.length === commands.length, 214 | `Expected to update ${commands.length} command${commands.length === 1 ? '' : 's'} but` + 215 | ` ${data.length} ${data.length === 1 ? 'was' : 'were'} successful.`, 216 | ); 217 | CookieLogger.debug( 218 | `Successfully PUT ${commands.length} command${commands.length === 1 ? '' : 's'} to the Discord API!`, 219 | ); 220 | return data; 221 | } 222 | 223 | /** 224 | * Populates the `commandId` field for each `CookieCommand` in the `command_tree`. 225 | * 226 | * @param data The data returned from the Discord API after deploying commands 227 | */ 228 | private async populateCommandIds(data: { id: string; name: string }[]) { 229 | const command_names_to_ids: Record = {}; 230 | for (const command_object of data) { 231 | command_names_to_ids[command_object.name] = command_object.id; 232 | } 233 | 234 | function traverseCommandTreeRecursively( 235 | command_id: string, 236 | current_command_tree: Record | CookieCommand, 237 | ) { 238 | if (CookieCommand.isCookieCommand(current_command_tree)) { 239 | const cmd: CookieCommand = current_command_tree; 240 | cmd.setId(command_id); 241 | } else { 242 | for (const tree_key of Object.keys(current_command_tree)) { 243 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 244 | traverseCommandTreeRecursively(command_id, current_command_tree[tree_key]); 245 | } 246 | } 247 | } 248 | 249 | for (const command of Object.keys(this.command_tree)) { 250 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 251 | traverseCommandTreeRecursively(command_names_to_ids[command], this.command_tree[command]); 252 | } 253 | } 254 | 255 | private async handleInteraction(interaction: Interaction) { 256 | if (!interaction.isChatInputCommand()) return; 257 | 258 | const full_command_name: string[] = [interaction.commandName]; 259 | function traverseOptionsRecursively(current_data: readonly CommandInteractionOption[] | undefined): void { 260 | if (current_data === undefined) { 261 | return; 262 | } 263 | for (const option of current_data) { 264 | switch (option.type) { 265 | case ApplicationCommandOptionType.SubcommandGroup: 266 | full_command_name.push(option.name); 267 | return traverseOptionsRecursively(option.options); 268 | case ApplicationCommandOptionType.Subcommand: 269 | full_command_name.push(option.name); 270 | return; 271 | } 272 | } 273 | } 274 | // Fill the `full_command_name` array based on a recursive traversal of the interaction options 275 | traverseOptionsRecursively(interaction.options.data); 276 | CookieLogger.debug(`Command '/${full_command_name.join(' ')}' invoked by user ${interaction.user.id}`); 277 | 278 | // Use the `full_command_name` to power logs & command discovery 279 | let command = this.command_tree; 280 | full_command_name.forEach(command_name => { 281 | if (!(command_name in command)) { 282 | CookieLogger.error(`Invariant: Missing '${command_name}' from command '/${full_command_name.join(' ')}'`); 283 | // We will ignore this because it is likely that our command tree does not match what is in the Discord API. 284 | // It is *technically* possible to reach this state if the command tree is updated recently, since the 285 | // Discord API can take an hour to update global commands. 286 | return; 287 | } 288 | command = command[command_name]; 289 | }); 290 | if (!CookieCommand.isCookieCommand(command)) { 291 | CookieLogger.error(`Invariant: Command '/${full_command_name.join(' ')}' is not a CookieCommand`); 292 | // Same issue as above. This can happen if the command tree is updated recently. 293 | // It shouldn't happen regularly, though 294 | return; 295 | } 296 | // Cast and execute if the command is present in our command tree 297 | try { 298 | await command.execute(interaction); 299 | CookieLogger.debug( 300 | `Command '/${command.getFullCommandName()}' successfully executed for user ${interaction.user.id}`, 301 | ); 302 | } catch (error) { 303 | CookieLogger.error(`Command '/${command.getFullCommandName()}' failed for user ${interaction.user.id}:`, error); 304 | if (interaction.replied || interaction.deferred) { 305 | await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true }); 306 | } else { 307 | await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true }); 308 | } 309 | } 310 | } 311 | } 312 | 313 | /** 314 | * `CookieCommand` is a wrapper for command files that can be executed by the `CookieClient` 315 | */ 316 | export class CookieCommand { 317 | private data: Record; 318 | private onExecute: CallableFunction; 319 | private fullCommandName: string[]; 320 | private id: string | undefined; 321 | private permissions: bigint | undefined; 322 | private root_permissions: bigint | undefined; 323 | 324 | public constructor(data: Record, executeFunction: CallableFunction, fullCommandName: string[]) { 325 | this.data = data; 326 | this.onExecute = executeFunction; 327 | this.fullCommandName = fullCommandName; 328 | this.id = undefined; 329 | this.permissions = undefined; 330 | this.root_permissions = undefined; 331 | } 332 | 333 | public static isCookieCommand(command: any): command is CookieCommand { 334 | return ( 335 | command && 336 | 'data' in command && 337 | (command as CookieCommand).data !== undefined && 338 | 'onExecute' in command && 339 | (command as CookieCommand).onExecute !== undefined 340 | ); 341 | } 342 | 343 | private static assertCommandDataIsValid(data: Record) { 344 | assert('description' in data, `Missing 'description' from command data: ${JSON.stringify(data, undefined, 2)}`); 345 | assert( 346 | data['description'].length >= 1 && data['description'].length <= 100, 347 | `'description' must be between 1 and 100 characters (found ${data['description'].length}): ${JSON.stringify( 348 | data, 349 | undefined, 350 | 2, 351 | )}`, 352 | ); 353 | if ('options' in data) { 354 | const options = data['options']; 355 | assert(Array.isArray(options), `'options' must be an array (found type: ${typeof options})`); 356 | for (const option of options.values()) { 357 | assert(typeof option === 'object', `'option' was not resolvable as json: ${String(option)}`); 358 | assert('description' in option, `Missing 'description' from 'option': ${JSON.stringify(option, undefined, 2)}`); 359 | assert( 360 | option.description.length >= 1 && option.description.length <= 100, 361 | `Option 'description' must be between 1 and 100 characters (found ${ 362 | option.description.length 363 | }): ${JSON.stringify(option, undefined, 2)}`, 364 | ); 365 | } 366 | } 367 | } 368 | 369 | public static fromFile(filePath: string): CookieCommand | undefined { 370 | try { 371 | // eslint-disable-next-line @typescript-eslint/no-var-requires 372 | const commandFile = require(filePath); 373 | assert('data' in commandFile, `Missing \`data\` export (expected type: json)`); 374 | assert('onExecute' in commandFile, `Missing \`onExecute\` export (expected type: async function)`); 375 | const jsonData = commandFile.data; 376 | const onExecute = commandFile.onExecute; 377 | // Type checking 378 | const jsonDataType = typeof jsonData; 379 | const onExecuteType = typeof onExecute; 380 | assert( 381 | jsonDataType === 'object', 382 | `\`data\` type must be json (expected type: 'object') (found type: ${jsonDataType})`, 383 | ); 384 | assert( 385 | onExecuteType === 'function', 386 | `\`onExecute\` type must be an async function (expected type: 'function') (found type: '${onExecuteType}')`, 387 | ); 388 | const full_command_name = filePath 389 | .substring(filePath.indexOf('bot/commands/') + 13, filePath.lastIndexOf('.js')) 390 | .split('/'); 391 | // If the name is present in the json data, we will force the command name to be the specified name 392 | if ('name' in jsonData) { 393 | full_command_name.pop(); 394 | full_command_name.push(String(jsonData.name)); 395 | } else { 396 | jsonData.name = full_command_name[full_command_name.length - 1]; 397 | } 398 | CookieCommand.assertCommandDataIsValid(jsonData as Record); 399 | CookieLogger.debug(`Created CookieCommand for command '/${full_command_name.join(' ')}'`); 400 | return new CookieCommand(jsonData as Record, onExecute as CallableFunction, full_command_name); 401 | } catch (err) { 402 | // TODO: uncomment once user ttls are implemented ! 403 | // CookieLogger.error(`Could not create CookieCommand from file ${filePath}:`, String(err)); 404 | return undefined; 405 | } 406 | } 407 | 408 | public setId(id: string) { 409 | this.id = id; 410 | } 411 | 412 | public setRootPermissions(permissions: bigint) { 413 | this.root_permissions = permissions; 414 | } 415 | 416 | public getRequiredPermissions(): bigint { 417 | if (this.permissions === undefined) { 418 | const perms = this.data['default_member_permissions']; 419 | if (perms === undefined || typeof perms !== 'string') { 420 | this.permissions = BigInt(0); 421 | } else { 422 | this.permissions = BigInt(perms); 423 | } 424 | } 425 | return this.permissions; 426 | } 427 | 428 | public getRequiredRootPermissions(): bigint { 429 | return this.root_permissions || BigInt(0); 430 | } 431 | 432 | public getMention(): string { 433 | if (this.id === undefined) { 434 | return '`/' + this.getFullCommandName() + '`'; 435 | } 436 | return ``; 437 | } 438 | 439 | public getFullCommandName(): string { 440 | return this.fullCommandName.join(' '); 441 | } 442 | 443 | public getName(): string { 444 | return this.data['name']; 445 | } 446 | 447 | public getJsonData(): Record { 448 | return this.data; 449 | } 450 | 451 | public async execute(interaction: ChatInputCommandInteraction) { 452 | if (this.getRequiredPermissions() > 0 && this.getRequiredRootPermissions() !== this.getRequiredPermissions()) { 453 | const permissions = interaction.memberPermissions?.bitfield; 454 | if (permissions === undefined) { 455 | return await interaction.reply({ 456 | content: 'I could not determine your permissions... :c Please try again', 457 | ephemeral: true, 458 | }); 459 | } 460 | const hasPermissions = (this.getRequiredPermissions() & permissions) === this.getRequiredPermissions(); 461 | if (!hasPermissions) { 462 | return await interaction.reply({ 463 | content: 'You do not have the necessary permissions to run this command :c', 464 | ephemeral: true, 465 | }); 466 | } 467 | } 468 | return await this.onExecute(this, interaction); 469 | } 470 | } 471 | 472 | interface CookieConfirmationResult { 473 | confirmed: boolean; 474 | cancelled: boolean; 475 | timedOut: boolean; 476 | } 477 | 478 | export class CookieConfirmationMenu { 479 | private response: ButtonInteraction | undefined; 480 | private result: CookieConfirmationResult; 481 | private promptPrefix: string; 482 | private promptMessage: string; 483 | private successPrefix: string; 484 | private successMessage: string; 485 | private cancelPrefix: string; 486 | private cancelMessage: string; 487 | private timeoutPrefix: string; 488 | private timeoutMessage: string; 489 | 490 | public constructor(public command: CookieCommand, public interaction: ChatInputCommandInteraction) { 491 | this.command = command; 492 | this.interaction = interaction; 493 | this.response = undefined; 494 | this.result = { confirmed: false, cancelled: false, timedOut: false }; 495 | this.promptPrefix = '૮ . . ྀིა'; 496 | this.promptMessage = 'Are you sure you would like to proceed?'; 497 | this.successPrefix = '(⸝⸝• ω •⸝⸝) ♡'; 498 | this.successMessage = `${command.getMention()} was confirmed.\n*It may take a moment for changes to take place.*`; 499 | this.cancelPrefix = '( ̄^ ̄ゞ'; 500 | this.cancelMessage = `${command.getMention()} was cancelled.\n*No changes were made.*`; 501 | this.timeoutPrefix = '( •́ ∧ •̀ )'; 502 | this.timeoutMessage = `${command.getMention()} did not receive user confirmation in time.\n*No changes were made.*`; 503 | } 504 | 505 | public setPromptPrefix(prefix: string) { 506 | this.promptPrefix = prefix; 507 | return this; 508 | } 509 | 510 | public setPromptMessage(message: string) { 511 | this.promptMessage = message; 512 | return this; 513 | } 514 | 515 | public setSuccessPrefix(prefix: string) { 516 | this.successPrefix = prefix; 517 | return this; 518 | } 519 | 520 | public setSuccessMessage(message: string) { 521 | this.successMessage = message; 522 | return this; 523 | } 524 | 525 | public setCancelPrefix(prefix: string) { 526 | this.cancelPrefix = prefix; 527 | return this; 528 | } 529 | 530 | public setCancelMessage(message: string) { 531 | this.cancelMessage = message; 532 | return this; 533 | } 534 | 535 | public setTimeoutPrefix(prefix: string) { 536 | this.timeoutPrefix = prefix; 537 | return this; 538 | } 539 | 540 | public setTimeoutMessage(message: string) { 541 | this.timeoutMessage = message; 542 | return this; 543 | } 544 | 545 | public isConfirmed(): boolean { 546 | return this.result.confirmed; 547 | } 548 | 549 | public isCancelled(): boolean { 550 | return this.result.cancelled; 551 | } 552 | 553 | public isTimedOut(): boolean { 554 | return this.result.timedOut; 555 | } 556 | 557 | public async prompt(): Promise { 558 | const confirm = new ButtonBuilder().setCustomId('confirm').setLabel('Confirm').setStyle(ButtonStyle.Success); 559 | 560 | const cancel = new ButtonBuilder().setCustomId('cancel').setLabel('Cancel').setStyle(ButtonStyle.Danger); 561 | 562 | const row = new ActionRowBuilder().addComponents(confirm, cancel); 563 | 564 | const promise = await this.interaction.reply({ 565 | embeds: [ 566 | { 567 | description: this.promptPrefix + (this.promptPrefix ? ' ' : '') + this.promptMessage, 568 | }, 569 | ], 570 | components: [row], 571 | ephemeral: true, 572 | }); 573 | 574 | const collectorFilter = (i: { user: { id: string } }) => i.user.id === this.interaction.user.id; 575 | 576 | try { 577 | const confirmation = await promise.awaitMessageComponent({ filter: collectorFilter, time: 60_000 }); 578 | assert(confirmation instanceof ButtonInteraction); 579 | this.response = confirmation; 580 | if (confirmation.customId === 'confirm') { 581 | this.result.confirmed = true; 582 | } else if (confirmation.customId === 'cancel') { 583 | this.result.cancelled = true; 584 | } 585 | } catch (e) { 586 | this.result.timedOut = true; 587 | } 588 | return this; 589 | } 590 | 591 | public async update() { 592 | if (this.isConfirmed()) { 593 | await this.response!.update({ 594 | embeds: [ 595 | { 596 | description: this.successPrefix + (this.successPrefix ? ' ' : '') + this.successMessage, 597 | }, 598 | ], 599 | components: [], 600 | }); 601 | } else if (this.isCancelled()) { 602 | await this.response!.update({ 603 | embeds: [ 604 | { 605 | description: this.cancelPrefix + (this.cancelPrefix ? ' ' : '') + this.cancelMessage, 606 | }, 607 | ], 608 | components: [], 609 | }); 610 | } else if (this.isTimedOut()) { 611 | await this.interaction.editReply({ 612 | embeds: [ 613 | { 614 | description: this.timeoutPrefix + (this.timeoutPrefix ? ' ' : '') + this.timeoutMessage, 615 | }, 616 | ], 617 | components: [], 618 | }); 619 | } 620 | } 621 | 622 | public async error(message: string = '') { 623 | const errorMessage = 624 | `( ꩜ ᯅ ꩜;) An unexpected error occurred while executing ${this.command.getMention()}` + 625 | (message ? `:\n\n${message}` : ''); 626 | if (this.response !== undefined) { 627 | await this.response.update({ 628 | embeds: [ 629 | { 630 | description: errorMessage, 631 | }, 632 | ], 633 | components: [], 634 | }); 635 | } else { 636 | await this.interaction.editReply({ 637 | embeds: [ 638 | { 639 | description: errorMessage, 640 | }, 641 | ], 642 | components: [], 643 | }); 644 | } 645 | } 646 | 647 | public async promptAndUpdate(): Promise { 648 | await this.prompt(); 649 | await this.update(); 650 | return this; 651 | } 652 | } 653 | 654 | /** 655 | * A console logs wrapper for cookie.ts-related logs. 656 | * cookie.ts uses it's own simple logger so that it can be used in other projects without a logger dependency. 657 | */ 658 | class CookieLogger { 659 | // State 660 | private static IS_SETUP = false; 661 | // Defaults 662 | private static COOKIE_DEBUG_LOGGING = false; 663 | private static COOKIE_INFO_LOGGING = true; 664 | private static COOKIE_ERROR_LOGGING = true; 665 | 666 | // I got the colour codes from here: https://ss64.com/nt/syntax-ansi.html 667 | 668 | /** 669 | * Startup helper function to process .env variables for logging 670 | * @param variable The name of the logging variable to process 671 | */ 672 | private static processLoggingEnv(variable: string) { 673 | if ((this as any)[variable] === undefined) { 674 | console.log('\x1b[32mCookieLogger does not have a variable called ' + variable); 675 | process.exit(1); 676 | } 677 | const value = process.env[variable]?.toLocaleLowerCase(); 678 | if (value !== undefined) { 679 | if (value === 'false') { 680 | (this as any)[variable] = false; 681 | } else if (value === 'true') { 682 | (this as any)[variable] = true; 683 | } else { 684 | console.log( 685 | '\x1b[32mInvalid value for variable "' + 686 | variable + 687 | '" in the .env (expected true or false, found ' + 688 | value + 689 | ')', 690 | ); 691 | process.exit(1); 692 | } 693 | } 694 | } 695 | 696 | /** 697 | * Initiate the Logger singleton & print a startup message 698 | */ 699 | private static startup() { 700 | this.processLoggingEnv('COOKIE_DEBUG_LOGGING'); 701 | this.processLoggingEnv('COOKIE_INFO_LOGGING'); 702 | this.processLoggingEnv('COOKIE_ERROR_LOGGING'); 703 | this.IS_SETUP = true; 704 | } 705 | 706 | public static debug(...args: any[]) { 707 | if (!this.IS_SETUP) { 708 | this.startup(); 709 | } 710 | if (!this.COOKIE_DEBUG_LOGGING) { 711 | return; 712 | } 713 | args.unshift('\x1b[0m[\x1b[34mcookie.ts\x1b[0m] [\x1b[90mDEBUG\x1b[0m]\x1b[90m'); 714 | args.push('\x1b[0m'); 715 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 716 | console.debug(...args); 717 | } 718 | 719 | public static info(...args: any[]) { 720 | if (!this.IS_SETUP) { 721 | this.startup(); 722 | } 723 | if (!this.COOKIE_INFO_LOGGING) { 724 | return; 725 | } 726 | args.unshift('\x1b[0m[\x1b[34mcookie.ts\x1b[0m] [\x1b[32mINFO\x1b[0m]\x1b[37m'); 727 | args.push('\x1b[0m'); 728 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 729 | console.info(...args); 730 | } 731 | 732 | public static error(...args: any[]) { 733 | if (!this.IS_SETUP) { 734 | this.startup(); 735 | } 736 | if (!this.COOKIE_ERROR_LOGGING) { 737 | return; 738 | } 739 | args.unshift('\x1b[0m[\x1b[34mcookie.ts\x1b[0m] [\x1b[31mERROR\x1b[0m]\x1b[93m'); 740 | args.push('\x1b[0m'); 741 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 742 | console.error(...args); 743 | } 744 | } 745 | -------------------------------------------------------------------------------- /src/bot/core.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-argument */ 2 | import { Collection, type GuildTextBasedChannel, Message, PermissionFlagsBits, User } from 'discord.js'; 3 | import { getServerChannelSettings, getServerSettings } from '../database/api'; 4 | import { debug, info } from '../logger'; 5 | import { bot } from './api'; 6 | 7 | const lastDeletedMessages: Record = {}; 8 | let numSingularDeletedMessages: number = 0; 9 | let numBulkDeletedMessages: number = 0; 10 | let numTotalDeletedMessages: number = 0; 11 | 12 | export async function continuallyRetrieveAndDeleteMessages(): Promise { 13 | const sleep = (ms: number) => new Promise(r => setTimeout(r, ms)); 14 | while (true) { 15 | debug('[bot/core] Running retrieveAndDeleteMessages()...'); 16 | numSingularDeletedMessages = 0; 17 | numBulkDeletedMessages = 0; 18 | numTotalDeletedMessages = 0; 19 | const startTime = Date.now(); 20 | await retrieveAndDeleteMessages(); 21 | const durationInSec = Math.round((Date.now() - startTime + Number.EPSILON) * 100) / 100000; 22 | if (numTotalDeletedMessages === 0) { 23 | debug(`[bot/core] No deletable messages were found (duration: ${durationInSec}s)`); 24 | } else { 25 | info( 26 | `[bot/core] Successfully deleted ${numTotalDeletedMessages} message${numTotalDeletedMessages !== 1 ? 's' : ''}`, 27 | `(bulk: ${numBulkDeletedMessages}, singular: ${numSingularDeletedMessages}) (duration: ${durationInSec}s)`, 28 | ); 29 | } 30 | if (durationInSec < 10) { 31 | await sleep(1000 * (10 - durationInSec)); // wait at least 10 seconds per retrieval loop (why not :3) 32 | } 33 | } 34 | } 35 | 36 | async function retrieveAndDeleteMessages(): Promise { 37 | for (const channel of bot.channels.cache.values()) { 38 | if (channel.isDMBased() || !channel.isTextBased()) { 39 | continue; 40 | } 41 | if (!canGetAndDeleteMessages(channel)) { 42 | continue; 43 | } 44 | if (!lastDeletedMessages[channel.id]) { 45 | lastDeletedMessages[channel.id] = channel.id; 46 | } 47 | try { 48 | const guildId: string = channel.guildId; 49 | const messages: Collection> = await channel.messages.fetch({ 50 | after: lastDeletedMessages[channel.id], 51 | cache: false, 52 | limit: 100, 53 | }); 54 | const deletableMessages = await collectDeletableMessages(guildId, channel.id, messages); 55 | const awaitedPromises = await handleDeletesForNonBulkDeletableMessages(guildId, channel.id, deletableMessages); 56 | if (awaitedPromises.length === 0) { 57 | await handleDeletesForBulkDeletableMessages(guildId, channel, deletableMessages); 58 | } 59 | } catch (err) { 60 | console.error(err); 61 | } 62 | } 63 | } 64 | 65 | function canGetAndDeleteMessages(channel: GuildTextBasedChannel): boolean { 66 | const me = channel.guild.members.me; 67 | if (!me) { 68 | return false; 69 | } 70 | const currentPerms = me.permissionsIn(channel); 71 | if ( 72 | !currentPerms.has(PermissionFlagsBits.ViewChannel) || 73 | !currentPerms.has(PermissionFlagsBits.ReadMessageHistory) || 74 | !currentPerms.has(PermissionFlagsBits.ManageMessages) 75 | ) { 76 | return false; 77 | } 78 | // Text-in-voice channels require Connect permissions, too (apparently) 79 | if (channel.isVoiceBased() && !currentPerms.has(PermissionFlagsBits.Connect)) { 80 | return false; 81 | } 82 | return true; 83 | } 84 | 85 | async function isMessageOlderThanTtl( 86 | serverId: string, 87 | channelId: string, 88 | message: { createdAt: { getTime: () => number }; author: User; pinned: boolean }, 89 | ): Promise { 90 | const channelSettings = await getServerChannelSettings(serverId, channelId); 91 | const serverSettings = await getServerSettings(serverId); 92 | const effectiveSettings = channelSettings.applyServerSettings(serverSettings); 93 | const ttl = effectiveSettings.getDefaultMessageTtl(); 94 | if (ttl === undefined) { 95 | return false; 96 | } 97 | if (message.pinned && !effectiveSettings.getIncludePinsByDefault()) { 98 | return false; 99 | } 100 | return message.createdAt.getTime() < Date.now() - ttl * 1000; 101 | } 102 | 103 | function canMessageBeBulkDeleted(message: { createdAt: { getTime: () => number } }): boolean { 104 | const bulkDeletionThresholdInMillis: number = 1000 * 60 * 60 * 24 * 14; // 14 days (Discord's bulk deletion threshold) 105 | return message.createdAt.getTime() > Date.now() - bulkDeletionThresholdInMillis; 106 | } 107 | 108 | async function collectDeletableMessages( 109 | guildId: string, 110 | channelId: string, 111 | messages: Collection>, 112 | ): Promise[]> { 113 | // this sinful mess is why you don't use js/ts ( •̀ - •́ ) 114 | return ( 115 | await Promise.all( 116 | messages 117 | .sort((a: { id: string }, b: { id: string }) => { 118 | return a.id.localeCompare(b.id); 119 | }) 120 | .map(async message => ({ 121 | value: message, 122 | include: await isMessageOlderThanTtl(guildId, channelId, message), 123 | })), 124 | ) 125 | ) 126 | .filter(v => v.include) 127 | .map(data => data.value); 128 | // source: https://stackoverflow.com/questions/71600782/async-inside-filter-function-in-javascript 129 | } 130 | 131 | /** 132 | * Messages older than Discord's bulk message deletion age limit cannot be 133 | * bulk deleted, so this method collects them and deletes them one-by-one. 134 | */ 135 | async function handleDeletesForNonBulkDeletableMessages( 136 | guildId: string, 137 | channelId: string, 138 | messages: Message[], 139 | ): Promise { 140 | return Promise.all( 141 | messages 142 | .filter((message: { createdAt: { getTime: () => number } }) => !canMessageBeBulkDeleted(message)) 143 | .map(async (message: { delete: () => any; id: string }) => { 144 | await message.delete(); 145 | debug(`[bot/core] Deleted message ${guildId}/${channelId}/${message.id}`); 146 | numTotalDeletedMessages++; 147 | numSingularDeletedMessages++; 148 | lastDeletedMessages[channelId] = message.id; 149 | }), 150 | ); 151 | } 152 | 153 | /** 154 | * Messages younger than Discord's bulk message deletion age limit can be 155 | * bulk deleted, so this method utilizes that feature to send batched delete requests. 156 | */ 157 | async function handleDeletesForBulkDeletableMessages( 158 | guildId: string, 159 | channel: GuildTextBasedChannel, 160 | messages: Message[], 161 | ): Promise>> { 162 | const messagesToDelete = messages.filter((message: { createdAt: { getTime: () => number } }) => 163 | canMessageBeBulkDeleted(message), 164 | ); 165 | if (messagesToDelete.length === 0) { 166 | return; 167 | } 168 | // https://discord.js.org/#/docs/main/stable/class/BaseGuildTextChannel?scrollTo=bulkDelete 169 | return channel.bulkDelete(messagesToDelete).then(deletedMessages => { 170 | let newestMessageId = '0'; 171 | numTotalDeletedMessages += deletedMessages.size; 172 | numBulkDeletedMessages += deletedMessages.size; 173 | deletedMessages.forEach((_message: any, snowflake: string) => { 174 | if (BigInt(newestMessageId) < BigInt(snowflake)) { 175 | newestMessageId = snowflake; 176 | } 177 | }); 178 | if (!lastDeletedMessages[channel.id] || BigInt(lastDeletedMessages[channel.id]) < BigInt(newestMessageId)) { 179 | lastDeletedMessages[channel.id] = newestMessageId; 180 | } 181 | }); 182 | } 183 | -------------------------------------------------------------------------------- /src/common/lock.ts: -------------------------------------------------------------------------------- 1 | // the TL;DR for why this lock needs to exist is because the database api is not async safe without it 2 | // source: https://jackpordi.com/posts/locks-in-js-because-why-not 3 | type Cont = () => void; 4 | 5 | export class Lock { 6 | private readonly queue: Cont[] = []; 7 | private acquired = false; 8 | 9 | public async acquire(): Promise { 10 | if (!this.acquired) { 11 | this.acquired = true; 12 | } else { 13 | return new Promise((resolve, _) => { 14 | this.queue.push(resolve); 15 | }); 16 | } 17 | } 18 | 19 | public async release(): Promise { 20 | if (this.queue.length === 0 && this.acquired) { 21 | this.acquired = false; 22 | return; 23 | } 24 | 25 | const continuation = this.queue.shift(); 26 | return new Promise((res: Cont) => { 27 | continuation!(); 28 | res(); 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/common/types.ts: -------------------------------------------------------------------------------- 1 | export const FOREVER_TTL: number = -1; 2 | // Friendly Defaults 3 | export const DEFAULT_MIN_MESSAGE_TTL: number | undefined = 30; 4 | export const DEFAULT_MAX_MESSAGE_TTL: number | undefined = undefined; // Forever 5 | export const DEFAULT_MESSAGE_TTL: number | undefined = undefined; // Forever 6 | export const DEFAULT_INCLUDE_PINS_BY_DEFAULT: boolean = false; 7 | 8 | export interface ServerSettingsData { 9 | serverId: string; 10 | channelId?: string | null; 11 | defaultMessageTtl?: number | null; 12 | maxMessageTtl?: number | null; 13 | minMessageTtl?: number | null; 14 | includePinsByDefault?: boolean | null; 15 | } 16 | 17 | export class ServerSettings { 18 | public static fromServerSettingsData(data: ServerSettingsData): ServerSettings { 19 | return new ServerSettings( 20 | data.serverId, 21 | data.defaultMessageTtl, 22 | data.maxMessageTtl, 23 | data.minMessageTtl, 24 | data.includePinsByDefault, 25 | ); 26 | } 27 | 28 | public constructor( 29 | public serverId: string, 30 | public defaultMessageTtl?: number | null, 31 | public maxMessageTtl?: number | null, 32 | public minMessageTtl?: number | null, 33 | public includePinsByDefault?: boolean | null, 34 | ) {} 35 | 36 | public getServerId(): string { 37 | return this.serverId; 38 | } 39 | 40 | public getDefaultMessageTtl(): number | undefined { 41 | if (this.defaultMessageTtl === FOREVER_TTL) { 42 | return undefined; 43 | } else if (this.defaultMessageTtl === null || this.defaultMessageTtl === undefined) { 44 | return DEFAULT_MESSAGE_TTL; 45 | } 46 | return this.defaultMessageTtl; 47 | } 48 | 49 | public getMaxMessageTtl(): number | undefined { 50 | if (this.maxMessageTtl === FOREVER_TTL) { 51 | return undefined; 52 | } 53 | if (this.maxMessageTtl === null || this.maxMessageTtl === undefined) { 54 | return DEFAULT_MAX_MESSAGE_TTL; 55 | } 56 | return this.maxMessageTtl; 57 | } 58 | 59 | public getMinMessageTtl(): number | undefined { 60 | if (this.minMessageTtl === FOREVER_TTL) { 61 | return undefined; 62 | } else if (this.minMessageTtl === null || this.minMessageTtl === undefined) { 63 | return DEFAULT_MIN_MESSAGE_TTL; 64 | } 65 | return this.minMessageTtl; 66 | } 67 | 68 | public getIncludePinsByDefault(): boolean { 69 | return this.includePinsByDefault ?? DEFAULT_INCLUDE_PINS_BY_DEFAULT; 70 | } 71 | 72 | public getData(): ServerSettingsData { 73 | return { 74 | serverId: this.serverId, 75 | channelId: null, 76 | defaultMessageTtl: this.defaultMessageTtl, 77 | maxMessageTtl: this.maxMessageTtl, 78 | minMessageTtl: this.minMessageTtl, 79 | includePinsByDefault: this.includePinsByDefault, 80 | }; 81 | } 82 | 83 | public clone(): ServerSettings { 84 | return new ServerSettings( 85 | this.serverId, 86 | this.defaultMessageTtl, 87 | this.maxMessageTtl, 88 | this.minMessageTtl, 89 | this.includePinsByDefault, 90 | ); 91 | } 92 | } 93 | 94 | export class ServerChannelSettings extends ServerSettings { 95 | public static fromServerSettingsData(data: ServerSettingsData): ServerChannelSettings { 96 | return new ServerChannelSettings( 97 | data.serverId, 98 | data.channelId!, 99 | data.defaultMessageTtl, 100 | data.maxMessageTtl, 101 | data.minMessageTtl, 102 | data.includePinsByDefault, 103 | ); 104 | } 105 | 106 | public constructor( 107 | public serverId: string, 108 | public channelId: string, 109 | public defaultMessageTtl?: number | null, 110 | public maxMessageTtl?: number | null, 111 | public minMessageTtl?: number | null, 112 | public includePinsByDefault?: boolean | null, 113 | ) { 114 | super(serverId, defaultMessageTtl, maxMessageTtl, minMessageTtl, includePinsByDefault); 115 | } 116 | 117 | public getChannelId(): string { 118 | return this.channelId; 119 | } 120 | 121 | public getData(): ServerSettingsData { 122 | return { 123 | serverId: this.serverId, 124 | channelId: this.channelId, 125 | defaultMessageTtl: this.defaultMessageTtl, 126 | maxMessageTtl: this.maxMessageTtl, 127 | minMessageTtl: this.minMessageTtl, 128 | includePinsByDefault: this.includePinsByDefault, 129 | }; 130 | } 131 | 132 | public clone(): ServerChannelSettings { 133 | return new ServerChannelSettings( 134 | this.serverId, 135 | this.channelId, 136 | this.defaultMessageTtl, 137 | this.maxMessageTtl, 138 | this.minMessageTtl, 139 | this.includePinsByDefault, 140 | ); 141 | } 142 | 143 | public applyServerSettings(serverSettings: ServerSettings): ServerChannelSettings { 144 | return new ServerChannelSettings( 145 | this.serverId, 146 | this.channelId, 147 | this.defaultMessageTtl !== undefined ? this.defaultMessageTtl : serverSettings.defaultMessageTtl, 148 | this.maxMessageTtl !== undefined ? this.maxMessageTtl : serverSettings.maxMessageTtl, 149 | this.minMessageTtl !== undefined ? this.minMessageTtl : serverSettings.minMessageTtl, 150 | this.includePinsByDefault !== undefined ? this.includePinsByDefault : serverSettings.includePinsByDefault, 151 | ); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/database/api.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { ServerChannelSettings, ServerSettings } from '../common/types'; 3 | import { Lock } from '../common/lock'; 4 | import { 5 | deleteAllServerSettings, 6 | selectServerChannelSettings, 7 | selectServerSettings, 8 | upsertServerChannelSettings, 9 | upsertServerSettings, 10 | } from './db'; 11 | import { 12 | clearCache, 13 | getCachedServerChannelSettings, 14 | getCachedServerSettings, 15 | setCachedServerChannelSettings, 16 | setCachedServerSettings, 17 | } from './cache'; 18 | dotenv.config(); 19 | 20 | const SERVER_SETTINGS_DB_LOCK = new Lock(); 21 | 22 | export async function getServerSettings(serverId: string): Promise { 23 | await SERVER_SETTINGS_DB_LOCK.acquire(); 24 | try { 25 | const cached = getCachedServerSettings(serverId); 26 | if (cached) { 27 | return cached; 28 | } 29 | let result = await selectServerSettings(serverId); 30 | if (result === undefined) { 31 | result = new ServerSettings(serverId); 32 | // no need to store default settings ╮ (. ❛ ᴗ ❛.) ╭ 33 | // await upsertServerSettings(result); 34 | } 35 | setCachedServerSettings(result); 36 | return result; 37 | } finally { 38 | await SERVER_SETTINGS_DB_LOCK.release(); 39 | } 40 | } 41 | 42 | export async function getServerChannelSettings(serverId: string, channelId: string): Promise { 43 | await SERVER_SETTINGS_DB_LOCK.acquire(); 44 | try { 45 | const cached = getCachedServerChannelSettings(serverId, channelId); 46 | if (cached) { 47 | return cached; 48 | } 49 | let result = await selectServerChannelSettings(serverId, channelId); 50 | if (result === undefined) { 51 | result = new ServerChannelSettings(serverId, channelId); 52 | // no need to store default settings ╮ (. ❛ ᴗ ❛.) ╭ 53 | // await upsertServerChannelSettings(result); 54 | } 55 | setCachedServerChannelSettings(result); 56 | return result; 57 | } finally { 58 | await SERVER_SETTINGS_DB_LOCK.release(); 59 | } 60 | } 61 | 62 | export async function setServerSettings(newServerSettings: ServerSettings): Promise { 63 | await SERVER_SETTINGS_DB_LOCK.acquire(); 64 | try { 65 | await upsertServerSettings(newServerSettings); 66 | setCachedServerSettings(newServerSettings); 67 | } finally { 68 | await SERVER_SETTINGS_DB_LOCK.release(); 69 | } 70 | } 71 | 72 | export async function setServerChannelSettings(newServerChannelSettings: ServerChannelSettings): Promise { 73 | await SERVER_SETTINGS_DB_LOCK.acquire(); 74 | try { 75 | await upsertServerChannelSettings(newServerChannelSettings); 76 | setCachedServerChannelSettings(newServerChannelSettings); 77 | } finally { 78 | await SERVER_SETTINGS_DB_LOCK.release(); 79 | } 80 | } 81 | 82 | export async function resetAllServerSettings(serverId: string): Promise { 83 | await SERVER_SETTINGS_DB_LOCK.acquire(); 84 | try { 85 | await deleteAllServerSettings(serverId); 86 | clearCache(serverId); 87 | } finally { 88 | await SERVER_SETTINGS_DB_LOCK.release(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/database/cache.ts: -------------------------------------------------------------------------------- 1 | import { ServerChannelSettings, ServerSettings } from '../common/types'; 2 | import { debug } from '../logger'; 3 | 4 | // serverId -> ServerSettings 5 | const serverSettingsCache = new Map(); 6 | // serverId/channelId -> ServerChannelSettings 7 | const serverChannelSettingsCache = new Map(); 8 | 9 | export function getCachedServerSettings(serverId: string): ServerSettings | undefined { 10 | return serverSettingsCache.get(serverId); 11 | } 12 | 13 | export function getCachedServerChannelSettings(serverId: string, channelId: string): ServerChannelSettings | undefined { 14 | return serverChannelSettingsCache.get(`${serverId}/${channelId}`); 15 | } 16 | 17 | export function setCachedServerSettings(newServerSettings: ServerSettings) { 18 | debug('[cache] setCachedServerSettings', JSON.stringify(newServerSettings, null, 2)); 19 | serverSettingsCache.set(newServerSettings.getServerId(), newServerSettings); 20 | } 21 | 22 | export function setCachedServerChannelSettings(newServerChannelSettings: ServerChannelSettings) { 23 | debug('[cache] setCachedServerChannelSettings', JSON.stringify(newServerChannelSettings, null, 2)); 24 | serverChannelSettingsCache.set( 25 | `${newServerChannelSettings.getServerId()}/${newServerChannelSettings.getChannelId()}`, 26 | newServerChannelSettings, 27 | ); 28 | } 29 | 30 | export function clearCache(serverId: string) { 31 | debug('[cache] clearCache', serverId); 32 | serverSettingsCache.delete(serverId); 33 | for (const key of serverChannelSettingsCache.keys()) { 34 | if (key.startsWith(serverId)) { 35 | serverChannelSettingsCache.delete(key); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/database/db.ts: -------------------------------------------------------------------------------- 1 | import { and, eq, isNull } from 'drizzle-orm'; 2 | import { migrate } from 'drizzle-orm/bun-sqlite/migrator'; 3 | import Database from 'bun:sqlite'; 4 | import { drizzle } from 'drizzle-orm/bun-sqlite'; 5 | import { debug } from '../logger'; 6 | import { ServerSettings, ServerChannelSettings, type ServerSettingsData } from '../common/types'; 7 | import { serverSettings } from './tables'; 8 | 9 | const sqlite = new Database('data/discord-ttl.db'); 10 | // docs: https://orm.drizzle.team/docs/get-started-sqlite#bun-sqlite 11 | // https://orm.drizzle.team/learn/guides/conditional-filters-in-query 12 | const db = drizzle(sqlite); 13 | migrate(db, { migrationsFolder: 'drizzle' }); 14 | 15 | export async function selectServerSettings(serverId: string): Promise { 16 | debug('[database] selectServerSettings', serverId); 17 | const result = await db 18 | .select() 19 | .from(serverSettings) 20 | .where(and(eq(serverSettings.serverId, serverId), isNull(serverSettings.channelId))) 21 | .execute(); 22 | if (result.length === 0) { 23 | return undefined; 24 | } 25 | return ServerSettings.fromServerSettingsData(result[0] as ServerSettingsData); 26 | } 27 | 28 | export async function selectServerChannelSettings( 29 | serverId: string, 30 | channelId: string, 31 | ): Promise { 32 | debug('[database] selectServerChannelSettings', serverId, channelId); 33 | const result = await db 34 | .select() 35 | .from(serverSettings) 36 | .where(and(eq(serverSettings.serverId, serverId), eq(serverSettings.channelId, channelId))) 37 | .execute(); 38 | if (result.length === 0) { 39 | return undefined; 40 | } 41 | return ServerChannelSettings.fromServerSettingsData(result[0] as ServerSettingsData); 42 | } 43 | 44 | export async function upsertServerSettings(newServerSettings: ServerSettings): Promise { 45 | debug('[database] upsertServerSettings', JSON.stringify(newServerSettings, null, 2)); 46 | await db 47 | .insert(serverSettings) 48 | .values(newServerSettings.getData()) 49 | .onConflictDoUpdate({ 50 | target: [serverSettings.serverId, serverSettings.channelId], 51 | set: newServerSettings.getData(), 52 | }) 53 | .execute(); 54 | } 55 | 56 | export async function upsertServerChannelSettings(newServerChannelSettings: ServerChannelSettings): Promise { 57 | debug('[database] upsertServerChannelSettings', JSON.stringify(newServerChannelSettings, null, 2)); 58 | await db 59 | .insert(serverSettings) 60 | .values(newServerChannelSettings.getData()) 61 | .onConflictDoUpdate({ 62 | target: [serverSettings.serverId, serverSettings.channelId], 63 | set: newServerChannelSettings.getData(), 64 | }) 65 | .execute(); 66 | } 67 | 68 | export async function deleteAllServerSettings(serverId: string): Promise { 69 | debug('[database] deleteAllServerSettings', serverId); 70 | await db.delete(serverSettings).where(eq(serverSettings.serverId, serverId)).execute(); 71 | } 72 | 73 | /* 74 | Set your own channel-level ttl: 75 | /my-ttl set current-channel `time:none (or time)` `include-pins:true (defaults false)` 76 | Set your own server-level ttl: 77 | /my-ttl set server-wide `time:none (or time)` `include-pins:true (defaults false)` 78 | Reset your server or channel ttls to defaults: 79 | /my-ttl reset current-channel 80 | /my-ttl reset server-wide `reset-all-channels:true (defaults false)` 81 | 82 | Set channel-level configs: 83 | /server-ttl configure current-channel `max-time:none` `min-time:1h` `default-time:none` `include-pins-by-default:true` 84 | Set server-level configs: 85 | /server-ttl configure server-wide `max-time:12h` `min-time:1h` `default-time:6h` `include-pins-by-default:true` 86 | Reset server-level ttl settings to defaults: 87 | /server-ttl clear current-channel 88 | /server-ttl clear server-wide `clear-all-channels:true (defaults false)` 89 | */ 90 | 91 | // server + channel; pins + ttl + disableUserTtls here 92 | // if ttl is missing, default to server setting 93 | // if disableUserTtls is missing, default to false 94 | // server; 95 | 96 | // some ttl and some pins setting 97 | 98 | // user + server + channel WHERE message_ttl < server_ttl 99 | // user + server WHERE message_ttl < server_ttl 100 | 101 | /* 102 | 103 | /server-ttl set ttl:1h delete-pins:false 104 | /channel-ttl set ttl:1h delete-pins:true 105 | /my-ttl set ttl:1h 106 | 107 | my ttl > channel ttl > server ttl 108 | channel disables > server disables 109 | */ 110 | -------------------------------------------------------------------------------- /src/database/tables.ts: -------------------------------------------------------------------------------- 1 | import { text, integer, sqliteTable, primaryKey } from 'drizzle-orm/sqlite-core'; 2 | 3 | // tables ૮ ˶ᵔ ᵕ ᵔ˶ ა 4 | // source: https://orm.drizzle.team/docs/column-types/sqlite 5 | // 6 | // if you make changes to this file, you must run `bun drizzle-kit generate:sqlite` 7 | // to generate the proper database migrations~ 8 | 9 | // user settings to be included in a future update ! 10 | 11 | // export const userSettings = sqliteTable('user_settings', { 12 | // userId: text('user_id').notNull(), 13 | // serverId: text('server_id'), 14 | // channelId: text('channel_id'), 15 | // messageTtl: integer('message_ttl'), // null represents no ttl 16 | // includePins: integer('include_pins', { mode: 'boolean' }), 17 | // }); 18 | 19 | export const serverSettings = sqliteTable( 20 | 'server_settings', 21 | { 22 | serverId: text('server_id').notNull(), 23 | channelId: text('channel_id'), 24 | defaultMessageTtl: integer('default_message_ttl'), // null represents no ttl 25 | maxMessageTtl: integer('max_message_ttl'), // null represents no max ttl 26 | minMessageTtl: integer('min_message_ttl'), // null represents no min ttl (30 seconds is hardcoded) 27 | includePinsByDefault: integer('include_pins_by_default', { mode: 'boolean' }), 28 | }, 29 | table => { 30 | return { 31 | pk: primaryKey({ columns: [table.serverId, table.channelId] }), 32 | }; 33 | }, 34 | ); 35 | -------------------------------------------------------------------------------- /src/database/tests/api.test.ts: -------------------------------------------------------------------------------- 1 | // import { expect, test } from "bun:test"; 2 | 3 | // test("2 + 2", () => { 4 | // expect(2 + 2).toBe(4); 5 | // }); 6 | 7 | // TODO: handle test database creation and deletion 8 | // TODO: add tests on test database to ensure apis function appropriately 9 | // TODO: add tests to ensure that the database can be migrated properly 10 | -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A console logs wrapper for prettier logs. 3 | * Console colour codes source: https://ss64.com/nt/syntax-ansi.html 4 | */ 5 | class Logger { 6 | private enabled: string[]; 7 | private disabled: string[]; 8 | // Defaults 9 | private DEBUG_LOGGING = false; 10 | private INFO_LOGGING = true; 11 | private ERROR_LOGGING = true; 12 | 13 | public constructor() { 14 | this.enabled = []; 15 | this.disabled = []; 16 | this.processLoggingEnvs(['DEBUG_LOGGING', 'INFO_LOGGING', 'ERROR_LOGGING']); 17 | } 18 | 19 | public printStartupMessage() { 20 | const getLogString = (logging_types: string[]) => { 21 | let log_string = ''; 22 | switch (logging_types.length) { 23 | case 1: 24 | log_string += logging_types[0]; 25 | break; 26 | case 2: 27 | log_string += logging_types[0] + ' and ' + logging_types[1]; 28 | break; 29 | default: 30 | for (let i = 0; i < logging_types.length; i++) { 31 | if (i === logging_types.length - 1) { 32 | log_string += 'and ' + logging_types[i]; 33 | } else { 34 | log_string += logging_types[i] + ', '; 35 | } 36 | } 37 | break; 38 | } 39 | log_string += ' logging ' + (logging_types.length > 1 ? 'have' : 'has'); 40 | return log_string; 41 | }; 42 | if (this.enabled.length > 0) { 43 | console.log('\x1b[32m' + getLogString(this.enabled) + ' been explicitly enabled via the .env\x1b[0m'); 44 | console.log(''); 45 | } 46 | if (this.disabled.length > 0) { 47 | console.log('\x1b[31m' + getLogString(this.disabled) + ' been explicitly disabled via the .env\x1b[0m'); 48 | console.log(''); 49 | } 50 | } 51 | 52 | /** 53 | * Startup helper function to process .env variables for logging 54 | * @param variable The name of the logging variable to process 55 | */ 56 | private processLoggingEnvs(variables: string[]) { 57 | for (const variable of variables) { 58 | if ((this as any)[variable] === undefined) { 59 | console.log('Logger does not have a variable called ' + variable); 60 | process.exit(1); 61 | } 62 | const value = process.env[variable]?.toLocaleLowerCase(); 63 | if (value !== undefined) { 64 | const logging_type = variable.split('_')[0]; 65 | if (value === 'false') { 66 | (this as any)[variable] = false; 67 | this.disabled.push(logging_type); 68 | } else if (value === 'true') { 69 | (this as any)[variable] = true; 70 | this.enabled.push(logging_type); 71 | } else { 72 | console.log( 73 | '\x1b[32mInvalid value for variable "' + 74 | variable + 75 | '" in the .env (expected true or false, found ' + 76 | value + 77 | ')', 78 | ); 79 | process.exit(1); 80 | } 81 | } 82 | } 83 | } 84 | 85 | public debug(...args: any[]) { 86 | if (!this.DEBUG_LOGGING) { 87 | return; 88 | } 89 | args.unshift('[\x1b[90mDEBUG\x1b[0m]\x1b[90m'); 90 | args.push('\x1b[0m'); 91 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 92 | console.debug(...args); 93 | } 94 | 95 | public info(...args: any[]) { 96 | if (!this.INFO_LOGGING) { 97 | return; 98 | } 99 | args.unshift('[\x1b[32mINFO\x1b[0m]\x1b[37m'); 100 | args.push('\x1b[0m'); 101 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 102 | console.info(...args); 103 | } 104 | 105 | public error(...args: any[]) { 106 | if (!this.ERROR_LOGGING) { 107 | return; 108 | } 109 | args.unshift('[\x1b[31mERROR\x1b[0m]\x1b[93m'); 110 | args.push('\x1b[0m'); 111 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 112 | console.error(...args); 113 | } 114 | } 115 | 116 | const logger = new Logger(); 117 | 118 | export function printStartupMessage() { 119 | logger.printStartupMessage(); 120 | } 121 | 122 | export function debug(...args: any[]) { 123 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 124 | logger.debug(...args); 125 | } 126 | 127 | export function info(...args: any[]) { 128 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 129 | logger.info(...args); 130 | } 131 | 132 | export function error(...args: any[]) { 133 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument 134 | logger.error(...args); 135 | } 136 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "rootDir": "src", 5 | "outDir": "dist", 6 | "module": "esnext", 7 | "target": "esnext", 8 | "lib": [ 9 | "esnext", 10 | "dom", 11 | ], 12 | 13 | // Bundler mode 14 | "moduleResolution": "bundler", 15 | "verbatimModuleSyntax": true, 16 | // These break compile for some reason? 17 | // "allowImportingTsExtensions": true, 18 | // "noEmit": true, 19 | 20 | // Best practices 21 | "strict": true, 22 | "skipLibCheck": true, 23 | "noUnusedLocals": false, 24 | "noUnusedParameters": false, 25 | "noFallthroughCasesInSwitch": true, 26 | // 27 | "alwaysStrict": true, 28 | "noImplicitReturns": true, 29 | "preserveConstEnums": true, 30 | "esModuleInterop": true, 31 | "resolveJsonModule": true, 32 | 33 | // Some stricter flags 34 | "useUnknownInCatchVariables": true, 35 | "noPropertyAccessFromIndexSignature": true, 36 | }, 37 | 38 | "include": [ 39 | "./src/**/*", 40 | "./test/**/*", 41 | "./.eslintrc.js", 42 | ], 43 | "exclude": [ 44 | "./drizzle.config.ts", 45 | ], 46 | } 47 | --------------------------------------------------------------------------------