├── .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 `${this.getFullCommandName()}:${this.id}>`;
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 |
--------------------------------------------------------------------------------