├── .commitlintrc.json
├── .dockerignore
├── .env.private.example
├── .env.public
├── .gitattributes
├── .github
├── auto_assign.yml
├── labels.yml
└── workflows
│ ├── deploy-manual.yml
│ ├── deploy.yml
│ ├── pr-automation.yml
│ ├── sync-labels.yml
│ └── test.yml
├── .gitignore
├── .husky
├── commit-msg
└── pre-commit
├── .prettierignore
├── .prettierrc.json
├── .vscode
└── settings.json
├── .yarn
└── releases
│ └── yarn-4.3.1.cjs
├── .yarnrc.yml
├── Dockerfile
├── LICENSE
├── README.md
├── actions
└── yarnCache
│ └── action.yml
├── apps
└── website
│ ├── .env.development
│ ├── .prettierrc.cjs
│ ├── .storybook
│ ├── main.ts
│ └── preview.ts
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.cjs
│ ├── public
│ └── assets
│ │ ├── chatsift.png
│ │ ├── favicon.ico
│ │ └── fonts
│ │ ├── Author-Bold.eot
│ │ ├── Author-Bold.ttf
│ │ ├── Author-Bold.woff
│ │ ├── Author-Bold.woff2
│ │ ├── Author-BoldItalic.eot
│ │ ├── Author-BoldItalic.ttf
│ │ ├── Author-BoldItalic.woff
│ │ ├── Author-BoldItalic.woff2
│ │ ├── Author-Extralight.eot
│ │ ├── Author-Extralight.ttf
│ │ ├── Author-Extralight.woff
│ │ ├── Author-Extralight.woff2
│ │ ├── Author-ExtralightItalic.eot
│ │ ├── Author-ExtralightItalic.ttf
│ │ ├── Author-ExtralightItalic.woff
│ │ ├── Author-ExtralightItalic.woff2
│ │ ├── Author-Italic.eot
│ │ ├── Author-Italic.ttf
│ │ ├── Author-Italic.woff
│ │ ├── Author-Italic.woff2
│ │ ├── Author-Light.eot
│ │ ├── Author-Light.ttf
│ │ ├── Author-Light.woff
│ │ ├── Author-Light.woff2
│ │ ├── Author-LightItalic.eot
│ │ ├── Author-LightItalic.ttf
│ │ ├── Author-LightItalic.woff
│ │ ├── Author-LightItalic.woff2
│ │ ├── Author-Medium.eot
│ │ ├── Author-Medium.ttf
│ │ ├── Author-Medium.woff
│ │ ├── Author-Medium.woff2
│ │ ├── Author-MediumItalic.eot
│ │ ├── Author-MediumItalic.ttf
│ │ ├── Author-MediumItalic.woff
│ │ ├── Author-MediumItalic.woff2
│ │ ├── Author-Regular.eot
│ │ ├── Author-Regular.ttf
│ │ ├── Author-Regular.woff
│ │ ├── Author-Regular.woff2
│ │ ├── Author-Semibold.eot
│ │ ├── Author-Semibold.ttf
│ │ ├── Author-Semibold.woff
│ │ ├── Author-Semibold.woff2
│ │ ├── Author-SemiboldItalic.eot
│ │ ├── Author-SemiboldItalic.ttf
│ │ ├── Author-SemiboldItalic.woff
│ │ ├── Author-SemiboldItalic.woff2
│ │ ├── Author-Variable.eot
│ │ ├── Author-Variable.ttf
│ │ ├── Author-Variable.woff
│ │ ├── Author-Variable.woff2
│ │ ├── Author-VariableItalic.eot
│ │ ├── Author-VariableItalic.ttf
│ │ ├── Author-VariableItalic.woff
│ │ └── Author-VariableItalic.woff2
│ ├── src
│ ├── app
│ │ ├── dashboard
│ │ │ ├── [id]
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ └── page.tsx
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ ├── page.tsx
│ │ └── providers.tsx
│ ├── components
│ │ ├── common
│ │ │ ├── Avatar.tsx
│ │ │ ├── Button.tsx
│ │ │ ├── Heading.tsx
│ │ │ ├── Logo.tsx
│ │ │ ├── SearchBar.tsx
│ │ │ ├── Sidebar.tsx
│ │ │ ├── Skeleton.stories.tsx
│ │ │ ├── Skeleton.tsx
│ │ │ ├── Toast.tsx
│ │ │ └── Toaster.tsx
│ │ ├── dashboard
│ │ │ ├── GuildCard.tsx
│ │ │ ├── GuildList.tsx
│ │ │ ├── GuildSearchBar.tsx
│ │ │ ├── RefreshGuildsButton.tsx
│ │ │ └── config
│ │ │ │ └── GuildConfigSidebar.tsx
│ │ ├── footer
│ │ │ └── Footer.tsx
│ │ ├── header
│ │ │ ├── Navbar.tsx
│ │ │ └── User.tsx
│ │ └── svg
│ │ │ ├── SvgAutoModerator.tsx
│ │ │ ├── SvgChatSift.tsx
│ │ │ ├── SvgClose.tsx
│ │ │ ├── SvgDarkTheme.tsx
│ │ │ ├── SvgDiscord.tsx
│ │ │ ├── SvgGitHub.tsx
│ │ │ ├── SvgHamburger.tsx
│ │ │ ├── SvgLightTheme.tsx
│ │ │ ├── SvgRefresh.tsx
│ │ │ └── SvgSearch.tsx
│ ├── data
│ │ ├── client.tsx
│ │ ├── common.ts
│ │ └── server.ts
│ ├── hooks
│ │ ├── useIsMounted.tsx
│ │ └── useToast.tsx
│ ├── middleware.ts
│ ├── styles
│ │ ├── author.css
│ │ └── globals.css
│ └── util
│ │ ├── constants.ts
│ │ ├── fetcher.tsx
│ │ └── util.ts
│ ├── tailwind.config.js
│ ├── tsconfig.eslint.json
│ └── tsconfig.json
├── build
└── caddy
│ ├── Caddyfile
│ └── Dockerfile
├── compose
├── docker-compose.yml
├── eslint.config.js
├── package.json
├── packages
├── npm
│ ├── discord-utils
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── __tests__
│ │ │ │ ├── embed.test.ts
│ │ │ │ └── sortChannels.test.ts
│ │ │ ├── embed.ts
│ │ │ ├── index.ts
│ │ │ └── sortChannels.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── parse-relative-time
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── __tests__
│ │ │ │ └── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ ├── pino-rotate-file
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── __tests__
│ │ │ │ └── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
│ └── readdir
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src
│ │ ├── RecursiveReaddirStream.ts
│ │ ├── __tests__
│ │ │ └── RecursiveReaddirStream.test.ts
│ │ └── index.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── tsup.config.ts
│ │ └── vitest.config.ts
├── services
│ ├── automoderator
│ │ ├── .prettierignore
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src
│ │ │ ├── cache
│ │ │ │ └── GuildCacheEntity.ts
│ │ │ ├── index.ts
│ │ │ ├── notifications
│ │ │ │ ├── INotifier.ts
│ │ │ │ └── Notifier.ts
│ │ │ └── registrations.ts
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ └── core
│ │ ├── .prettierignore
│ │ ├── README.md
│ │ ├── package.json
│ │ ├── src
│ │ ├── Env.ts
│ │ ├── README.md
│ │ ├── broker-types
│ │ │ └── gateway.ts
│ │ ├── cache
│ │ │ ├── CacheFactory.ts
│ │ │ ├── ICache.ts
│ │ │ ├── ICacheEntity.ts
│ │ │ ├── README.md
│ │ │ └── RedisCache.ts
│ │ ├── command-framework
│ │ │ ├── CoralCommandHandler.ts
│ │ │ ├── ICommandHandler.ts
│ │ │ └── handlers
│ │ │ │ ├── README.md
│ │ │ │ └── dev.ts
│ │ ├── container.ts
│ │ ├── database
│ │ │ ├── IDatabase.ts
│ │ │ ├── KyselyPostgresDatabase.ts
│ │ │ └── README.md
│ │ ├── db.ts
│ │ ├── experiments
│ │ │ ├── ExperimentHandler.ts
│ │ │ ├── IExperimentHandler.ts
│ │ │ └── README.md
│ │ ├── index.ts
│ │ ├── registrations.ts
│ │ └── util
│ │ │ ├── DependencyManager.ts
│ │ │ ├── README.md
│ │ │ ├── encode.ts
│ │ │ └── setupCrashLogs.ts
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
└── shared
│ ├── .prettierignore
│ ├── README.md
│ ├── package.json
│ ├── src
│ ├── README.md
│ ├── api
│ │ ├── auth
│ │ │ └── auth.ts
│ │ ├── bots
│ │ │ ├── schema.ts
│ │ │ └── types.ts
│ │ └── index.ts
│ ├── index.ts
│ └── util
│ │ ├── PermissionsBitField.ts
│ │ ├── README.md
│ │ ├── computeAvatar.ts
│ │ ├── promiseAllObject.ts
│ │ ├── setEquals.ts
│ │ └── userToEmbedData.ts
│ ├── tsconfig.eslint.json
│ └── tsconfig.json
├── prisma
├── migrations
│ ├── 20250502101410_init
│ │ └── migration.sql
│ └── migration_lock.toml
└── schema.prisma
├── services
├── api
│ ├── package.json
│ ├── src
│ │ ├── core-handlers
│ │ │ ├── error.ts
│ │ │ └── setup.ts
│ │ ├── handlers
│ │ │ ├── auth.ts
│ │ │ └── bots.ts
│ │ ├── index.ts
│ │ ├── server.ts
│ │ ├── struct
│ │ │ ├── Auth.ts
│ │ │ └── StateCookie.ts
│ │ └── util
│ │ │ ├── discordAuth.ts
│ │ │ └── replyHelpers.ts
│ ├── tsconfig.eslint.json
│ └── tsconfig.json
└── automoderator
│ ├── discord-proxy
│ ├── package.json
│ ├── src
│ │ ├── cache.ts
│ │ ├── clone.ts
│ │ ├── index.ts
│ │ └── server.ts
│ ├── tsconfig.eslint.json
│ └── tsconfig.json
│ ├── gateway
│ ├── package.json
│ ├── src
│ │ ├── gateway.ts
│ │ ├── index.ts
│ │ └── server.ts
│ ├── tsconfig.eslint.json
│ └── tsconfig.json
│ ├── interactions
│ ├── package.json
│ ├── src
│ │ ├── handlers
│ │ │ ├── cases.ts
│ │ │ ├── history.ts
│ │ │ ├── mod.ts
│ │ │ └── purge.ts
│ │ ├── helpers
│ │ │ └── verifyValidCaseReferences.ts
│ │ ├── index.ts
│ │ └── state
│ │ │ ├── IComponentStateStore.ts
│ │ │ └── RedisComponentDataStore.ts
│ ├── tsconfig.eslint.json
│ └── tsconfig.json
│ └── observer
│ ├── package.json
│ ├── src
│ └── index.ts
│ ├── tsconfig.eslint.json
│ └── tsconfig.json
├── tsconfig.eslint.json
├── tsconfig.json
├── tsup.config.ts
├── turbo.json
├── vitest.config.ts
└── yarn.lock
/.commitlintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["@commitlint/config-angular"],
3 | "rules": {
4 | "type-enum": [
5 | 2,
6 | "always",
7 | ["chore", "build", "ci", "docs", "feat", "fix", "perf", "refactor", "revert", "style", "test", "types", "typings"]
8 | ],
9 | "scope-case": [0]
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | .git
2 | .github
3 | .idea
4 | .vscode
5 | .husky
6 |
7 | **/node_modules
8 | **/types
9 | **/.next
10 | **/dist
11 |
12 | **/vitest.config.ts
13 | **/eslint.config.js
14 | **/.prettierrc.json
15 | **/.prettierrc.cjs
16 | **/.prettierignore
17 | **/tsconfig.eslint.json
18 |
19 | .commitlinrrc.json
20 | .gitattributes
21 | .gitignore
22 | .env.private.example
23 | logs-archive
24 |
25 | LICENSE
26 | **/README.md
27 |
28 | .DS_Store
29 |
--------------------------------------------------------------------------------
/.env.private.example:
--------------------------------------------------------------------------------
1 | NODE_ENV=dev # obviously, set to prod in prod.
2 |
3 | AUTOMODERATOR_DISCORD_TOKEN=boop
4 |
5 | SECRET_SIGNING_KEY=boop # generate using node -e "console.log(require('crypto').randomBytes(32).toString('base64'));"
6 | OAUTH_DISCORD_CLIENT_SECRET=boop
7 |
8 | # Used by Caddy directly via docker-compose for SSL certs
9 | CF_API_TOKEN=boop
10 | LOCAL_DOZZLE_PORT=8080
11 |
--------------------------------------------------------------------------------
/.env.public:
--------------------------------------------------------------------------------
1 | LOGS_DIR=/var/chatsift-logs
2 | ROOT_DOMAIN=automoderator.app
3 |
4 | POSTGRES_HOST=postgres
5 | POSTGRES_PORT=5432
6 | POSTGRES_USER=chatsift
7 | POSTGRES_PASSWORD=admin
8 | POSTGRES_DATABASE=chatsift
9 |
10 | REDIS_URL=redis://redis:6379
11 |
12 | ADMINS=223703707118731264
13 |
14 | AUTOMODERATOR_DISCORD_CLIENT_ID=878278456629157939
15 | AUTOMODERATOR_GATEWAY_URL=http://automoderator-gateway:9000
16 | AUTOMODERATOR_PROXY_URL=http://automoderator-proxy:9000
17 |
18 | API_PORT=9876
19 | PUBLIC_API_URL_DEV=http://localhost:9876
20 | PUBLIC_API_URL_PROD=https://api-canary.automoderator.app
21 | OAUTH_DISCORD_CLIENT_ID=1005791929075769344
22 | CORS="http:\/\/localhost:3000|https:\/\/canary\.automoderator\.app"
23 | ALLOWED_API_ORIGINS=http://localhost:3000,https://canary.automoderator.app
24 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto eol=lf
2 |
--------------------------------------------------------------------------------
/.github/auto_assign.yml:
--------------------------------------------------------------------------------
1 | addReviewers: true
2 | reviewers:
3 | - didinele
4 | numberOfReviewers: 0
5 | runOnDraft: true
6 |
--------------------------------------------------------------------------------
/.github/labels.yml:
--------------------------------------------------------------------------------
1 | - name: 'backlog'
2 | color: '7ef7ef'
3 | - name: 'bug'
4 | color: 'd73a4a'
5 | - name: 'chore'
6 | color: 'ffffff'
7 | - name: 'ci'
8 | color: '0075ca'
9 | - name: 'dependencies'
10 | color: '276bd1'
11 | - name: 'documentation'
12 | color: '0075ca'
13 | - name: 'duplicate'
14 | color: 'cfd3d7'
15 | - name: 'feature request'
16 | color: 'fcf95a'
17 | - name: 'good first issue'
18 | color: '7057ff'
19 | - name: 'has PR'
20 | color: '4b1f8e'
21 | - name: 'help wanted'
22 | color: '008672'
23 | - name: 'in progress'
24 | color: 'ffccd7'
25 | - name: 'in review'
26 | color: 'aed5fc'
27 | - name: 'invalid'
28 | color: 'e4e669'
29 | - name: 'need repro'
30 | color: 'c66037'
31 | - name: 'performance'
32 | color: '80c042'
33 | - name: 'priority:high'
34 | color: 'fc1423'
35 | - name: 'refactor'
36 | color: '1d637f'
37 | - name: 'regression'
38 | color: 'ea8785'
39 | - name: 'semver:major'
40 | color: 'c10f47'
41 | - name: 'semver:minor'
42 | color: 'e4f486'
43 | - name: 'semver:patch'
44 | color: 'e8be8b'
45 | - name: 'tests'
46 | color: 'f06dff'
47 | - name: 'wontfix'
48 | color: 'ffffff'
49 |
--------------------------------------------------------------------------------
/.github/workflows/deploy-manual.yml:
--------------------------------------------------------------------------------
1 | name: Deploy manual
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | deploy:
8 | name: Manual deploy
9 | runs-on: ubuntu-latest
10 | env:
11 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
12 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
13 |
14 | steps:
15 | - name: Checkout repository
16 | uses: actions/checkout@v2
17 |
18 | - name: Install node.js
19 | uses: actions/setup-node@v3
20 | with:
21 | node-version: 22
22 |
23 | - name: Set up Docker Buildx
24 | uses: docker/setup-buildx-action@v1
25 |
26 | - name: Login to DockerHub
27 | run: docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
28 |
29 | - name: Install dependencies
30 | uses: ./actions/yarnCache
31 |
32 | - name: Build the images
33 | run: docker build -t chatsift/chatsift-next:latest -f ./Dockerfile .
34 |
35 | - name: Tag all
36 | run: yarn turbo run tag-docker --force --no-cache
37 |
38 | - name: Push to DockerHub
39 | run: docker image push --all-tags chatsift/chatsift-next
40 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Deploy
2 |
3 | on:
4 | workflow_run:
5 | workflows:
6 | - 'Quality Check'
7 | branches:
8 | - main
9 | types:
10 | - completed
11 |
12 | jobs:
13 | deploy:
14 | name: Deploy
15 | runs-on: ubuntu-latest
16 | if: ${{ github.event.workflow_run.conclusion == 'success' }}
17 | env:
18 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
19 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
20 |
21 | steps:
22 | - name: Checkout repository
23 | uses: actions/checkout@v2
24 | with:
25 | fetch-depth: 2
26 |
27 | - name: Install node.js
28 | uses: actions/setup-node@v3
29 | with:
30 | node-version: 22
31 |
32 | - name: Set up Docker Buildx
33 | uses: docker/setup-buildx-action@v1
34 |
35 | - name: Login to DockerHub
36 | run: docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }}
37 |
38 | - name: Install dependencies
39 | uses: ./actions/yarnCache
40 |
41 | - name: Build the images
42 | run: docker build -t chatsift/chatsift-next:latest -f ./Dockerfile .
43 |
44 | - name: Tag changes
45 | run: yarn turbo run tag-docker --filter '...[HEAD~1...HEAD~0]'
46 |
47 | - name: Push to DockerHub
48 | run: docker image push --all-tags chatsift/chatsift-next
49 |
--------------------------------------------------------------------------------
/.github/workflows/pr-automation.yml:
--------------------------------------------------------------------------------
1 | name: 'PR Automation'
2 |
3 | on:
4 | pull_request_target:
5 |
6 | jobs:
7 | triage:
8 | runs-on: ubuntu-latest
9 | steps:
10 | - name: Automatically assign reviewers
11 | if: github.event.action == 'opened'
12 | uses: kentaro-m/auto-assign-action@v1.2.1
13 |
--------------------------------------------------------------------------------
/.github/workflows/sync-labels.yml:
--------------------------------------------------------------------------------
1 | name: Sync Labels
2 |
3 | on:
4 | schedule:
5 | - cron: '0 0 * * *'
6 | workflow_dispatch:
7 | push:
8 | branches:
9 | - main
10 | paths:
11 | - '.github/labels.yml'
12 |
13 | jobs:
14 | synclabels:
15 | name: Sync Labels
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout repository
19 | uses: actions/checkout@v2
20 |
21 | - name: Sync labels
22 | uses: crazy-max/ghaction-github-labeler@v3
23 | with:
24 | github-token: ${{ secrets.GITHUB_TOKEN }}
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Quality Check
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | quality:
7 | name: Quality Check
8 | runs-on: ubuntu-latest
9 | env:
10 | TURBO_TEAM: ${{ vars.TURBO_TEAM }}
11 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
12 | steps:
13 | - name: Checkout repository
14 | uses: actions/checkout@v3
15 |
16 | - name: Install node.js
17 | uses: actions/setup-node@v3
18 | with:
19 | node-version: 22
20 |
21 | - name: Install dependencies
22 | uses: ./actions/yarnCache
23 |
24 | - name: Ensure prisma schema is up to date
25 | run: yarn prisma generate
26 |
27 | - name: Build
28 | run: yarn build
29 |
30 | - name: ESLint
31 | run: yarn lint
32 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .vscode/*
2 | !.vscode/settings.json
3 | .DS_STORE
4 |
5 | **/node_modules
6 | **/.next
7 |
8 | **/coverage
9 | **/dist
10 | **/.turbo
11 |
12 | .yarn/*
13 | !.yarn/patches
14 | !.yarn/plugins
15 | !.yarn/releases
16 | !.yarn/sdks
17 | !.yarn/versions
18 | .pnp.*
19 |
20 | logs-archive
21 | .env.private
22 |
23 | *storybook.log
24 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | yarn commitlint --edit $1
2 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | yarn build && yarn lint && yarn test
2 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | node_modules/*
2 | coverage/*
3 | **/dist/*
4 | .yarn/*
5 | .turbo
6 |
7 | packages/services/core/src/db.ts
8 |
--------------------------------------------------------------------------------
/.prettierrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "useTabs": true,
4 | "singleQuote": true,
5 | "quoteProps": "as-needed",
6 | "trailingComma": "all",
7 | "endOfLine": "lf"
8 | }
9 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "typescript.tsdk": "node_modules/typescript/lib"
3 | }
4 |
--------------------------------------------------------------------------------
/.yarnrc.yml:
--------------------------------------------------------------------------------
1 | compressionLevel: mixed
2 |
3 | enableGlobalCache: false
4 |
5 | nodeLinker: node-modules
6 |
7 | yarnPath: .yarn/releases/yarn-4.3.1.cjs
8 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:22-alpine
2 | LABEL name "chatsift-next"
3 |
4 | WORKDIR /usr/
5 |
6 | RUN apk add --update \
7 | && apk add --no-cache ca-certificates \
8 | && apk add --no-cache --virtual .build-deps curl git python3 alpine-sdk
9 |
10 | COPY turbo.json package.json tsconfig.json yarn.lock .yarnrc.yml tsup.config.ts ./
11 | COPY .yarn ./.yarn
12 |
13 | # NPM package.json
14 | COPY packages/npm/discord-utils/package.json ./packages/npm/discord-utils/package.json
15 | COPY packages/npm/parse-relative-time/package.json ./packages/npm/parse-relative-time/package.json
16 | COPY packages/npm/pino-rotate-file/package.json ./packages/npm/pino-rotate-file/package.json
17 | COPY packages/npm/readdir/package.json ./packages/npm/readdir/package.json
18 |
19 | # services package.json
20 | COPY packages/services/automoderator/package.json ./packages/services/automoderator/package.json
21 | COPY packages/services/core/package.json ./packages/services/core/package.json
22 |
23 | # shared package.json
24 | COPY packages/shared/package.json ./packages/shared/package.json
25 |
26 | # root services package.json
27 | COPY services/api/package.json ./services/api/package.json
28 |
29 | # automoderator services package.json
30 | COPY services/automoderator/discord-proxy/package.json ./services/automoderator/discord-proxy/package.json
31 | COPY services/automoderator/gateway/package.json ./services/automoderator/gateway/package.json
32 | COPY services/automoderator/interactions/package.json ./services/automoderator/interactions/package.json
33 | COPY services/automoderator/observer/package.json ./services/automoderator/observer/package.json
34 |
35 | RUN yarn workspaces focus --all
36 |
37 | COPY prisma ./prisma
38 | RUN yarn prisma generate
39 |
40 | # NPM
41 | COPY packages/npm/discord-utils ./packages/npm/discord-utils
42 | COPY packages/npm/parse-relative-time ./packages/npm/parse-relative-time
43 | COPY packages/npm/pino-rotate-file ./packages/npm/pino-rotate-file
44 | COPY packages/npm/readdir ./packages/npm/readdir
45 |
46 | # services
47 | COPY packages/services/automoderator ./packages/services/automoderator
48 | COPY packages/services/core ./packages/services/core
49 |
50 | # shared
51 | COPY packages/shared ./packages/shared
52 |
53 | # root services
54 | COPY services/api ./services/api
55 |
56 | # automoderator services
57 | COPY services/automoderator/discord-proxy ./services/automoderator/discord-proxy
58 | COPY services/automoderator/gateway ./services/automoderator/gateway
59 | COPY services/automoderator/interactions ./services/automoderator/interactions
60 | COPY services/automoderator/observer ./services/automoderator/observer
61 |
62 | ARG TURBO_TEAM
63 | ENV TURBO_TEAM=$TURBO_TEAM
64 |
65 | ARG TURBO_TOKEN
66 | ENV TURBO_TOKEN=$TURBO_TOKEN
67 |
68 | RUN yarn turbo run build
69 |
70 | RUN yarn workspaces focus --all --production
71 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (C) 2021-2023 ChatSift
2 |
3 | This program is free software: you can redistribute it and/or modify
4 | it under the terms of the GNU Affero General Public License as
5 | published by the Free Software Foundation, either version 3 of the
6 | License, or (at your option) any later version.
7 |
8 | This program is distributed in the hope that it will be useful,
9 | but WITHOUT ANY WARRANTY; without even the implied warranty of
10 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 | GNU Affero General Public License for more details.
12 |
13 | You should have received a copy of the GNU Affero General Public License
14 | along with this program. If not, see .
15 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # chatsift
2 |
3 | Monorepo for all of our bots and their common utilities, along with some NPM packages.
4 |
5 | ## Licensing
6 |
7 | This project is lincensed under the GNU AGPLv3 license. View the full file [here](./LICENSE).
8 |
--------------------------------------------------------------------------------
/actions/yarnCache/action.yml:
--------------------------------------------------------------------------------
1 | # Source: https://github.com/discordjs/discord.js/blob/7196fe36e8089dde7bcaf0db4dd09cf524125e0c/packages/actions/src/yarnCache/action.yml
2 |
3 | # Full credits to discord.js and its contributors, below is the original Apache 2.0 LICENSE file:
4 | # https://github.com/discordjs/discord.js/blob/7196fe36e8089dde7bcaf0db4dd09cf524125e0c/LICENSE
5 |
6 | name: 'yarn install'
7 | description: 'Run yarn install with node_modules linker and cache enabled'
8 | runs:
9 | using: 'composite'
10 | steps:
11 | - name: Expose yarn config as "$GITHUB_OUTPUT"
12 | id: yarn-config
13 | shell: bash
14 | run: |
15 | echo "CACHE_FOLDER=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT
16 |
17 | - name: Restore yarn cache
18 | uses: actions/cache@v3
19 | id: yarn-download-cache
20 | with:
21 | path: ${{ steps.yarn-config.outputs.CACHE_FOLDER }}
22 | key: yarn-download-cache-${{ hashFiles('yarn.lock') }}
23 | restore-keys: |
24 | yarn-download-cache-
25 |
26 | - name: Restore yarn install state
27 | id: yarn-install-state-cache
28 | uses: actions/cache@v3
29 | with:
30 | path: .yarn/ci-cache/
31 | key: ${{ runner.os }}-yarn-install-state-cache-${{ hashFiles('yarn.lock', '.yarnrc.yml') }}
32 |
33 | - name: Install dependencies
34 | shell: bash
35 | run: |
36 | yarn install --immutable --inline-builds
37 | env:
38 | YARN_ENABLE_GLOBAL_CACHE: 'false'
39 | YARN_NM_MODE: 'hardlinks-local'
40 | YARN_INSTALL_STATE_PATH: .yarn/ci-cache/install-state.gz
41 |
--------------------------------------------------------------------------------
/apps/website/.env.development:
--------------------------------------------------------------------------------
1 | NEXT_PUBLIC_API_URL=http://localhost:9876
2 |
--------------------------------------------------------------------------------
/apps/website/.prettierrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | ...require('../../.prettierrc.json'),
3 | plugins: ['prettier-plugin-tailwindcss'],
4 | };
5 |
--------------------------------------------------------------------------------
/apps/website/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/nextjs';
2 | import { join, dirname } from 'path';
3 |
4 | /**
5 | * This function is used to resolve the absolute path of a package.
6 | * It is needed in projects that use Yarn PnP or are set up within a monorepo.
7 | */
8 | function getAbsolutePath(value: string): any {
9 | return dirname(require.resolve(join(value, 'package.json')));
10 | }
11 | const config: StorybookConfig = {
12 | stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
13 | addons: [
14 | getAbsolutePath('@storybook/addon-onboarding'),
15 | getAbsolutePath('@storybook/addon-links'),
16 | getAbsolutePath('@storybook/addon-essentials'),
17 | getAbsolutePath('@chromatic-com/storybook'),
18 | getAbsolutePath('@storybook/addon-interactions'),
19 | ],
20 | framework: {
21 | name: getAbsolutePath('@storybook/nextjs'),
22 | options: {},
23 | },
24 | staticDirs: ['../public'],
25 | features: {
26 | experimentalRSC: true,
27 | },
28 | };
29 |
30 | export default config;
31 |
--------------------------------------------------------------------------------
/apps/website/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Preview } from '@storybook/react';
2 |
3 | import '../src/styles/globals.css';
4 |
5 | const preview: Preview = {
6 | parameters: {
7 | controls: {
8 | matchers: {
9 | color: /(background|color)$/i,
10 | date: /Date$/i,
11 | },
12 | },
13 | },
14 | };
15 |
16 | export default preview;
17 |
--------------------------------------------------------------------------------
/apps/website/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
6 |
--------------------------------------------------------------------------------
/apps/website/next.config.mjs:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('next').NextConfig}
3 | */
4 | export default {
5 | reactStrictMode: true,
6 | images: {
7 | dangerouslyAllowSVG: true,
8 | contentDispositionType: 'attachment',
9 | contentSecurityPolicy: "default-src 'self'; frame-src 'none'; sandbox;",
10 | remotePatterns: [
11 | {
12 | protocol: 'https',
13 | hostname: 'cdn.discordapp.com',
14 | pathname: '/icons/**',
15 | },
16 | ],
17 | },
18 | productionBrowserSourceMaps: true,
19 | logging: {
20 | fetches: {
21 | fullUrl: true,
22 | },
23 | },
24 | async redirects() {
25 | return [
26 | {
27 | source: '/github',
28 | destination: 'https://github.com/chatsift',
29 | permanent: true,
30 | },
31 | {
32 | source: '/support',
33 | destination: 'https://discord.gg/tgZ2pSgXXv',
34 | permanent: true,
35 | },
36 | {
37 | source: '/invites/automoderator',
38 | destination:
39 | 'https://discord.com/api/oauth2/authorize?client_id=847081327950168104&permissions=1100451531910&scope=applications.commands%20bot',
40 | permanent: true,
41 | },
42 | ];
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/apps/website/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/website",
3 | "private": true,
4 | "version": "1.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "build:check": "tsc --noEmit",
8 | "build": "next build",
9 | "dev": "next dev",
10 | "lint": "eslint src",
11 | "storybook": "storybook dev -p 6006",
12 | "build-storybook": "storybook build"
13 | },
14 | "devDependencies": {
15 | "@chromatic-com/storybook": "^1.9.0",
16 | "@hapi/boom": "^10.0.1",
17 | "@storybook/addon-essentials": "^8.6.12",
18 | "@storybook/addon-interactions": "^8.6.12",
19 | "@storybook/addon-links": "^8.6.12",
20 | "@storybook/addon-onboarding": "^8.6.12",
21 | "@storybook/blocks": "^8.6.12",
22 | "@storybook/nextjs": "^8.6.12",
23 | "@storybook/react": "^8.6.12",
24 | "@storybook/test": "^8.6.12",
25 | "@tailwindcss/typography": "^0.5.16",
26 | "@tanstack/react-query-devtools": "^5.75.0",
27 | "@types/node": "^22.15.3",
28 | "@types/react": "^18.3.20",
29 | "@types/react-dom": "^18.3.7",
30 | "autoprefixer": "^10.4.21",
31 | "postcss": "^8.5.3",
32 | "storybook": "^8.6.12",
33 | "tailwindcss": "^3.4.17",
34 | "typescript": "^5.8.3",
35 | "vercel": "^34.4.0"
36 | },
37 | "dependencies": {
38 | "@chatsift/shared": "workspace:^",
39 | "@radix-ui/react-avatar": "^1.1.7",
40 | "@radix-ui/react-navigation-menu": "^1.2.10",
41 | "@radix-ui/react-toast": "^1.2.11",
42 | "@react-icons/all-files": "^4.1.0",
43 | "@tanstack/react-query": "^5.75.0",
44 | "@use-gesture/react": "^10.3.1",
45 | "class-variance-authority": "^0.7.1",
46 | "clsx": "^2.1.1",
47 | "discord-api-types": "^0.38.2",
48 | "jotai": "^2.12.3",
49 | "lucide-react": "^0.424.0",
50 | "next": "^14.2.28",
51 | "next-themes": "^0.3.0",
52 | "prettier-plugin-tailwindcss": "^0.6.11",
53 | "react": "^18.3.1",
54 | "react-aria": "^3.39.0",
55 | "react-aria-components": "^1.8.0",
56 | "react-dom": "^18.3.1",
57 | "react-spring": "^9.7.5",
58 | "tailwind-merge": "^2.6.0",
59 | "tailwindcss-animate": "^1.0.7",
60 | "usehooks-ts": "^3.1.1"
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/apps/website/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/apps/website/public/assets/chatsift.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/chatsift.png
--------------------------------------------------------------------------------
/apps/website/public/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/favicon.ico
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Bold.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Bold.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Bold.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Bold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Bold.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-BoldItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-BoldItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-BoldItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-BoldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-BoldItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Extralight.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Extralight.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Extralight.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Extralight.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Extralight.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-ExtralightItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-ExtralightItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-ExtralightItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-ExtralightItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-ExtralightItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Italic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Italic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Italic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Italic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Light.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Light.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Light.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Light.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Light.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-LightItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-LightItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-LightItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-LightItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-LightItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Medium.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Medium.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Medium.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Medium.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Medium.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-MediumItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-MediumItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-MediumItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-MediumItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-MediumItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Regular.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Regular.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Regular.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Regular.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Semibold.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Semibold.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Semibold.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Semibold.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Semibold.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-SemiboldItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-SemiboldItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-SemiboldItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-SemiboldItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-SemiboldItalic.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Variable.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Variable.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Variable.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-Variable.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-Variable.woff2
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.eot:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-VariableItalic.eot
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-VariableItalic.ttf
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.woff:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-VariableItalic.woff
--------------------------------------------------------------------------------
/apps/website/public/assets/fonts/Author-VariableItalic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ChatSift/chatsift/caf4683914dde830d2139407893b238465585123/apps/website/public/assets/fonts/Author-VariableItalic.woff2
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { PropsWithChildren } from 'react';
2 | import GuildConfigSidebar from '~/components/dashboard/config/GuildConfigSidebar';
3 |
4 | export default async function DashboardGuildLayout({ children }: PropsWithChildren) {
5 | return (
6 |
7 |
8 | {children}
9 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Snowflake } from 'discord-api-types/v10';
2 | import type { Metadata } from 'next';
3 | import { server } from '~/data/server';
4 |
5 | interface Props {
6 | readonly params: {
7 | readonly id: Snowflake;
8 | };
9 | }
10 |
11 | export async function generateMetadata({ params: { id } }: Props): Promise {
12 | const me = await server.me.fetch();
13 | const guild = me?.guilds.find((guild) => guild.id === id);
14 |
15 | return {
16 | title: `${guild?.name ?? 'Unknown'}`,
17 | };
18 | }
19 |
20 | export default async function DashboardGuildPage({ params: { id: guildId } }: Props) {
21 | return <>>;
22 | }
23 |
--------------------------------------------------------------------------------
/apps/website/src/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { Suspense } from 'react';
3 | import Heading from '~/components/common/Heading';
4 | import GuildList from '~/components/dashboard/GuildList';
5 | import GuildSearchBar from '~/components/dashboard/GuildSearchBar';
6 | import RefreshGuildsButton from '~/components/dashboard/RefreshGuildsButton';
7 |
8 | export const metadata: Metadata = {
9 | title: 'Dashboard',
10 | };
11 |
12 | export default async function DashboardPage() {
13 | return (
14 | <>
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | >
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/apps/website/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { HydrationBoundary } from '@tanstack/react-query';
2 | import type { Metadata } from 'next';
3 | import type { PropsWithChildren } from 'react';
4 | import { Providers } from '~/app/providers';
5 | import { Toaster } from '~/components/common/Toaster';
6 | import Footer from '~/components/footer/Footer';
7 | import Navbar from '~/components/header/Navbar';
8 | import { server } from '~/data/server';
9 | import { cn } from '~/util/util';
10 |
11 | import '~/styles/globals.css';
12 |
13 | export const metadata: Metadata = {
14 | title: {
15 | template: '%s | ChatSift',
16 | default: 'ChatSift',
17 | },
18 | icons: {
19 | other: [{ rel: 'icon', url: '/assets/favicon.ico' }],
20 | },
21 | };
22 |
23 | export default async function RootLayout({ children }: PropsWithChildren) {
24 | return (
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
37 | {children}
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
--------------------------------------------------------------------------------
/apps/website/src/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import Button from '~/components/common/Button';
3 | import Heading from '~/components/common/Heading';
4 |
5 | export default function NotFound() {
6 | return (
7 |
8 |
9 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/apps/website/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { ExternalLink } from 'lucide-react';
2 |
3 | export default async function HomePage() {
4 | return (
5 | <>
6 | Modern solutions for your Discord communities
7 |
8 |
18 | >
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/apps/website/src/app/providers.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { ToastAction } from '@radix-ui/react-toast';
4 | import { isServer, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
5 | import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
6 | import { Provider as JotaiProvider } from 'jotai';
7 | import Link from 'next/link';
8 | import { ThemeProvider } from 'next-themes';
9 | import type { PropsWithChildren } from 'react';
10 | import { useToast, type ToastFn } from '~/hooks/useToast';
11 | import { APIError } from '~/util/fetcher';
12 |
13 | let browserQueryClient: QueryClient | undefined;
14 |
15 | function getQueryClient(toast: ToastFn) {
16 | const base = {
17 | defaultOptions: {
18 | queries: {
19 | staleTime: 60 * 1_000,
20 | },
21 | },
22 | };
23 |
24 | if (isServer) {
25 | return new QueryClient(base);
26 | }
27 |
28 | return (browserQueryClient ??= new QueryClient({
29 | ...base,
30 | queryCache: new QueryCache({
31 | onError: (error) => {
32 | if (error instanceof APIError) {
33 | if (error.payload.statusCode === 403) {
34 | toast?.({
35 | title: 'Forbidden',
36 | description: "You don't have permission to view or edit this config.",
37 | variant: 'destructive',
38 | action: (
39 |
40 | Go back
41 |
42 | ),
43 | });
44 | } else if (error.payload.statusCode >= 500 && error.payload.statusCode < 600) {
45 | toast?.({
46 | title: 'Server Error',
47 | description: 'A server error occurred while processing your request.',
48 | variant: 'destructive',
49 | });
50 | }
51 | } else {
52 | toast?.({
53 | title: 'Network Error',
54 | description: 'A network error occurred while processing your request.',
55 | variant: 'destructive',
56 | });
57 | }
58 | },
59 | }),
60 | }));
61 | }
62 |
63 | export function Providers({ children }: PropsWithChildren) {
64 | const { toast } = useToast();
65 | const queryClient = getQueryClient(toast);
66 |
67 | return (
68 |
69 |
70 | {children}
71 |
72 |
73 |
74 | );
75 | }
76 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Avatar.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/prop-types */
2 |
3 | 'use client';
4 |
5 | import * as AvatarPrimitive from '@radix-ui/react-avatar';
6 | import * as React from 'react';
7 | import { cn } from '~/util/util';
8 |
9 | export const Avatar = React.forwardRef<
10 | React.ElementRef,
11 | React.ComponentPropsWithoutRef
12 | >(({ className, ...props }, ref) => (
13 |
18 | ));
19 | Avatar.displayName = AvatarPrimitive.Root.displayName;
20 |
21 | export const AvatarImage = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef
24 | >(({ className, ...props }, ref) => (
25 |
26 | ));
27 | AvatarImage.displayName = AvatarPrimitive.Image.displayName;
28 |
29 | export const AvatarFallback = React.forwardRef<
30 | React.ElementRef,
31 | React.ComponentPropsWithoutRef
32 | >(({ className, ...props }, ref) => (
33 |
38 | ));
39 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
40 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Button.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ButtonProps } from 'react-aria-components';
4 | import { Button as AriaButton } from 'react-aria-components';
5 | import { cn } from '~/util/util';
6 |
7 | export default function Button(props: ButtonProps) {
8 | const { className, ...rest } = props;
9 |
10 | return (
11 |
18 | {props.children}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Heading.tsx:
--------------------------------------------------------------------------------
1 | interface HeadingProps {
2 | readonly subtitle?: string | undefined;
3 | readonly title: string;
4 | }
5 |
6 | export default function Heading({ title, subtitle }: HeadingProps) {
7 | return (
8 |
9 |
{title}
10 | {subtitle &&
{subtitle}
}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Logo.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 | import React from 'react';
3 | import SvgChatSift from '~/components/svg/SvgChatSift';
4 |
5 | export default function Logo() {
6 | return (
7 |
8 |
9 |
10 | ChatSift
11 |
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/SearchBar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useSearchParams, useRouter, usePathname } from 'next/navigation';
4 | import { useCallback, useEffect, useRef, useState } from 'react';
5 | import type { AriaSearchFieldProps } from 'react-aria';
6 | import { useSearchField } from 'react-aria';
7 | import SvgSearch from '~/components/svg/SvgSearch';
8 | import { cn } from '~/util/util';
9 |
10 | const DEBOUNCE_TIME = 300;
11 |
12 | export default function SearchBar({ className, ...props }: AriaSearchFieldProps & { readonly className?: string }) {
13 | const searchParams = useSearchParams();
14 | const pathname = usePathname();
15 | const router = useRouter();
16 |
17 | const [value, setValue] = useState(searchParams.get('search') ?? '');
18 | const ref = useRef(null);
19 | const { inputProps } = useSearchField(props, { value, setValue }, ref);
20 |
21 | const update = useCallback(() => {
22 | const params = new URLSearchParams(searchParams.toString());
23 | params.set('search', value);
24 |
25 | router.replace(`${pathname}?${params.toString()}`);
26 | }, [searchParams, value, router, pathname]);
27 |
28 | useEffect(() => {
29 | const timeout = setTimeout(update, DEBOUNCE_TIME);
30 | return () => clearTimeout(timeout);
31 | }, [update]);
32 |
33 | return (
34 |
35 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Skeleton.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import Skeleton from './Skeleton';
3 |
4 | export default {
5 | title: 'Skeleton',
6 | component: Skeleton,
7 | tags: ['autodocs'],
8 | } satisfies Meta;
9 |
10 | type Story = StoryObj;
11 |
12 | export const Default = {
13 | render: ({ ...args }) => ,
14 | args: {
15 | className: 'h-12',
16 | },
17 | } satisfies Story;
18 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from '~/util/util';
2 |
3 | export default function Skeleton({ className, ...props }: React.HTMLAttributes) {
4 | return (
5 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/components/common/Toaster.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from '~/components/common/Toast';
11 | import { useToast } from '~/hooks/useToast';
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast();
15 |
16 | return (
17 |
18 | {toasts.map(({ id, title, description, action, ...props }) => {
19 | return (
20 |
21 |
22 | {title && {title}}
23 | {description && {description}}
24 |
25 | {action}
26 |
27 |
28 | );
29 | })}
30 |
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/apps/website/src/components/dashboard/GuildCard.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { UserMeGuild } from '@chatsift/shared';
4 | import Image from 'next/image';
5 | import SvgAutoModerator from '~/components/svg/SvgAutoModerator';
6 | import { cn } from '~/util/util';
7 |
8 | interface GuildCardProps {
9 | readonly data: UserMeGuild;
10 | }
11 |
12 | function getGuildAcronym(guildName: string) {
13 | return guildName
14 | .replaceAll("'s ", ' ')
15 | .replaceAll(/\w+/g, (substring) => substring[0]!)
16 | .replaceAll(/\s/g, '');
17 | }
18 |
19 | export default function GuildCard({ data }: GuildCardProps) {
20 | const hasBots = data.bots.length > 0;
21 | const icon = data?.icon ? `https://cdn.discordapp.com/icons/${data.id}/${data.icon}.png` : null;
22 | const url = hasBots ? `/dashboard/${data.id}` : undefined;
23 |
24 | return (
25 |
31 |
48 |
49 |
50 | {data.name}
51 |
52 |
53 | {hasBots ? (
54 |
55 | <>
56 | {data.bots.includes('automoderator') && (
57 | -
58 |
59 |
60 | )}
61 | >
62 |
63 | ) : (
64 | <>
65 |
66 | Not invited
67 |
68 |
69 |
Invite a bot:
70 |
77 |
78 | >
79 | )}
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/apps/website/src/components/dashboard/GuildList.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useSearchParams } from 'next/navigation';
4 | import { useMemo } from 'react';
5 | import GuildCard from '~/components/dashboard/GuildCard';
6 | import { client } from '~/data/client';
7 |
8 | export default function GuildList() {
9 | const { data } = client.useMe();
10 | const searchParams = useSearchParams();
11 |
12 | const searchQuery = searchParams.get('search') ?? '';
13 |
14 | const sorted = useMemo(() => {
15 | const lower = searchQuery.toLowerCase();
16 |
17 | if (!data) {
18 | return [];
19 | }
20 |
21 | const filtered = data.guilds.filter((guild) => guild.name.toLowerCase().includes(lower));
22 | return filtered.reverse().sort((a, b) => {
23 | return b.bots.length - a.bots.length;
24 | });
25 | }, [data, searchQuery]);
26 |
27 | return (
28 |
29 | {sorted.map((guild) => (
30 | -
31 |
32 |
33 | ))}
34 |
35 | );
36 | }
37 |
--------------------------------------------------------------------------------
/apps/website/src/components/dashboard/GuildSearchBar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import SearchBar from '~/components/common/SearchBar';
4 | import { client } from '~/data/client';
5 |
6 | export default function GuildSearchBar() {
7 | const { isLoading } = client.useMe();
8 |
9 | return ;
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/components/dashboard/RefreshGuildsButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useState } from 'react';
4 | import Button from '~/components/common/Button';
5 | import SvgRefresh from '~/components/svg/SvgRefresh';
6 | import { client } from '~/data/client';
7 | import { useToast } from '~/hooks/useToast';
8 |
9 | export default function RefreshGuildsButton() {
10 | const { refetch, isLoading } = client.useMe();
11 | const { toast, dismiss } = useToast();
12 | const [lastToastId, setLastToastId] = useState(null);
13 |
14 | return (
15 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/apps/website/src/components/dashboard/config/GuildConfigSidebar.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { BotId } from '@chatsift/shared';
4 | import { useRouter } from 'next/navigation';
5 | import Sidebar from '~/components/common/Sidebar';
6 | import { client } from '~/data/client';
7 |
8 | interface Props {
9 | readonly currentBot?: BotId;
10 | }
11 |
12 | export default function GuildConfigSidebar({ currentBot }: Props) {
13 | const router = useRouter();
14 | const { data: bots } = client.useBots();
15 |
16 | return :3;
17 | }
18 |
--------------------------------------------------------------------------------
/apps/website/src/components/footer/Footer.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useTheme } from 'next-themes';
4 | import Button from '~/components/common/Button';
5 | import Skeleton from '~/components/common/Skeleton';
6 | import SvgDarkTheme from '~/components/svg/SvgDarkTheme';
7 | import SvgDiscord from '~/components/svg/SvgDiscord';
8 | import SvgGitHub from '~/components/svg/SvgGitHub';
9 | import SvgLightTheme from '~/components/svg/SvgLightTheme';
10 | import { useIsMounted } from '~/hooks/useIsMounted';
11 |
12 | function ThemeSwitchButton() {
13 | const { theme, setTheme } = useTheme();
14 |
15 | return (
16 |
25 | );
26 | }
27 |
28 | export default function Footer() {
29 | const isMounted = useIsMounted();
30 |
31 | return (
32 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/apps/website/src/components/header/User.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { UserMe } from '@chatsift/shared';
4 | import { CDNRoutes, ImageFormat, RouteBases, type DefaultUserAvatarAssets } from 'discord-api-types/v10';
5 | import { Avatar, AvatarImage } from '~/components/common/Avatar';
6 | import Button from '~/components/common/Button';
7 | import Skeleton from '~/components/common/Skeleton';
8 | import { client } from '~/data/client';
9 | import { URLS } from '~/util/constants';
10 | import { APIError } from '~/util/fetcher';
11 |
12 | function LoginButton() {
13 | return (
14 |
17 | );
18 | }
19 |
20 | function ErrorHandler({ error }: { readonly error: Error }) {
21 | if (error instanceof APIError && error.payload.statusCode === 401) {
22 | return ;
23 | }
24 |
25 | return <>Error>;
26 | }
27 |
28 | interface UserAvatarProps {
29 | readonly className: string;
30 | readonly isLoading: boolean;
31 | readonly user: UserMe | undefined;
32 | }
33 |
34 | function UserAvatar({ isLoading, user, className }: UserAvatarProps) {
35 | const avatarUrl = user?.avatar
36 | ? `${RouteBases.cdn}${CDNRoutes.userAvatar(user.id, user.avatar, ImageFormat.PNG)}`
37 | : `${RouteBases.cdn}${CDNRoutes.defaultUserAvatar(Number((BigInt(user?.id ?? '0') >> 22n) % 6n) as DefaultUserAvatarAssets)}`;
38 |
39 | return (
40 |
41 | {isLoading ? : }
42 |
43 | );
44 | }
45 |
46 | export function UserDesktop() {
47 | const { isLoading, data: user, error } = client.useMe();
48 |
49 | if (error) {
50 | return ;
51 | }
52 |
53 | // As always, null implies pre-fetch came back empty with a 401
54 | return user ? (
55 | <>
56 |
59 |
60 | >
61 | ) : (
62 |
63 | );
64 | }
65 |
66 | export function UserMobile() {
67 | const { isLoading, data: user, error } = client.useMe();
68 |
69 | if (error) {
70 | return ;
71 | }
72 |
73 | return user ? (
74 |
83 | ) : (
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgAutoModerator.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgAutoModerator({ width, height }: { readonly height?: number; readonly width?: number }) {
2 | return (
3 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgChatSift.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgChatSift() {
2 | return (
3 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgClose.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgClose() {
2 | return (
3 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgDarkTheme.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgDarkTheme() {
2 | return (
3 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgDiscord.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgDiscord() {
2 | return (
3 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgGitHub.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgGitHub() {
2 | return (
3 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgHamburger.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgHamburger() {
2 | return (
3 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgLightTheme.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgLightTheme() {
2 | return (
3 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgRefresh.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgRefresh() {
2 | return (
3 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/apps/website/src/components/svg/SvgSearch.tsx:
--------------------------------------------------------------------------------
1 | export default function SvgSearch({ className }: { readonly className?: string }) {
2 | return (
3 |
11 |
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/apps/website/src/data/client.tsx:
--------------------------------------------------------------------------------
1 | import type { BotId, UserMe } from '@chatsift/shared';
2 | import { useQuery } from '@tanstack/react-query';
3 | import { routesInfo, type MakeOptions } from '~/data/common';
4 | import clientSideFetcher, { APIError, clientSideErrorHandler } from '~/util/fetcher';
5 | import { exponentialBackOff, retryWrapper } from '~/util/util';
6 |
7 | function make({ path, queryKey }: MakeOptions) {
8 | function useQueryIt() {
9 | return useQuery({
10 | queryKey,
11 | queryFn: clientSideFetcher({ path, method: 'GET' }),
12 | throwOnError: clientSideErrorHandler({ throwOverride: false }),
13 | refetchOnWindowFocus: false,
14 | retry: retryWrapper((retries, error) => {
15 | if (error instanceof APIError) {
16 | return retries < 5 && error.payload.statusCode !== 401;
17 | }
18 |
19 | return retries < 3;
20 | }),
21 | retryDelay: exponentialBackOff,
22 | });
23 | }
24 |
25 | return useQueryIt;
26 | }
27 |
28 | export const client = {
29 | useMe: make(routesInfo.me),
30 | useBots: make(routesInfo.bots),
31 | useBot: (bot: BotId) => make(routesInfo.bots.bot(bot))(),
32 | } as const;
33 |
--------------------------------------------------------------------------------
/apps/website/src/data/common.ts:
--------------------------------------------------------------------------------
1 | import type { BotId } from '@chatsift/shared';
2 |
3 | export interface MakeOptions {
4 | readonly path: `/${string}`;
5 | readonly queryKey: readonly [string, ...string[]];
6 | }
7 |
8 | interface Info {
9 | readonly [Key: string]: Info | MakeOptions | ((...params: any[]) => MakeOptions);
10 | }
11 |
12 | export const routesInfo = {
13 | me: {
14 | queryKey: ['userMe'],
15 | path: '/auth/discord/@me',
16 | },
17 | bots: {
18 | queryKey: ['bots'],
19 | path: '/bots',
20 | bot: (bot: BotId) =>
21 | ({
22 | queryKey: ['bots', bot],
23 | path: `/bots/${bot}`,
24 | }) as const,
25 | },
26 | } as const satisfies Info;
27 |
--------------------------------------------------------------------------------
/apps/website/src/data/server.ts:
--------------------------------------------------------------------------------
1 | import type { BotId, UserMe } from '@chatsift/shared';
2 | import { dehydrate, QueryClient, type DehydratedState } from '@tanstack/react-query';
3 | import { headers } from 'next/headers';
4 | import { routesInfo, type MakeOptions } from '~/data/common';
5 |
6 | function make({ queryKey, path }: MakeOptions) {
7 | async function fetchIt(): Promise {
8 | const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}${path}`, {
9 | credentials: 'include',
10 | headers: {
11 | cookie: headers().get('cookie') ?? '',
12 | },
13 | });
14 |
15 | if (res.status === 401) {
16 | return null;
17 | }
18 |
19 | if (!res.ok) {
20 | throw new Error('Failed to fetch');
21 | }
22 |
23 | return res.json();
24 | }
25 |
26 | async function prefetch(): Promise {
27 | const client = new QueryClient();
28 |
29 | await client.prefetchQuery({
30 | queryKey,
31 | queryFn: fetchIt,
32 | });
33 |
34 | return dehydrate(client);
35 | }
36 |
37 | return {
38 | // Sometimes we need the clean fetch function to be used on the server. prefetch just call onto it
39 | // and runs dehydration.
40 | fetch: fetchIt,
41 | prefetch,
42 | };
43 | }
44 |
45 | export const server = {
46 | me: make(routesInfo.me),
47 | bots: make(routesInfo.bots),
48 | bot: (bot: BotId) => make(routesInfo.bots.bot(bot)),
49 |
50 | prefetchMany: async (options: readonly MakeOptions[]): Promise => {
51 | const client = new QueryClient();
52 | const calls = options.map(async (option) => client.prefetchQuery(option));
53 |
54 | await Promise.all(calls);
55 | return dehydrate(client);
56 | },
57 | } as const;
58 |
--------------------------------------------------------------------------------
/apps/website/src/hooks/useIsMounted.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | export function useIsMounted(): boolean {
4 | const [mounted, setMounted] = useState(false);
5 |
6 | // See https://github.com/pacocoursey/next-themes?tab=readme-ov-file#avoid-hydration-mismatch
7 | useEffect(() => {
8 | setMounted(true);
9 | }, []);
10 |
11 | return mounted;
12 | }
13 |
--------------------------------------------------------------------------------
/apps/website/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 | import type { NextRequest } from 'next/server';
3 | import { server } from '~/data/server';
4 | import { URLS } from '~/util/constants';
5 |
6 | export async function middleware(request: NextRequest) {
7 | const user = await server.me.fetch().catch(() => null);
8 |
9 | if (!user) {
10 | return NextResponse.redirect(new URL(URLS.API.LOGIN, request.url));
11 | }
12 | }
13 |
14 | export const config = {
15 | matcher: '/dashboard/:path*',
16 | };
17 |
--------------------------------------------------------------------------------
/apps/website/src/styles/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import url('author.css');
6 |
7 | body {
8 | font-family: Author-Variable, sans-serif;
9 | font-feature-settings:
10 | 'pnum' on,
11 | 'lnum' on;
12 | }
13 |
14 | .hide-for-mobile-override {
15 | & > *:nth-child(1) {
16 | display: none;
17 | }
18 |
19 | /* tailwind md: */
20 | @media (max-width: 768px) {
21 | & > * {
22 | display: none;
23 | }
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/apps/website/src/util/constants.ts:
--------------------------------------------------------------------------------
1 | export const URLS = {
2 | API: {
3 | LOGIN: `${process.env.NEXT_PUBLIC_API_URL}/auth/discord?redirect_path=/dashboard`,
4 | LOGOUT: `${process.env.NEXT_PUBLIC_API_URL}/auth/discord/logout`,
5 | },
6 | INVITES: {
7 | AUTOMODERATOR: 'https://discord.com/oauth2/authorize?client_id=242730576195354624&permissions=8&scope=bot',
8 | },
9 | } as const;
10 |
--------------------------------------------------------------------------------
/apps/website/src/util/fetcher.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { Payload } from '@hapi/boom';
4 |
5 | export class APIError extends Error {
6 | public constructor(
7 | public readonly payload: Payload,
8 | public readonly method: string,
9 | ) {
10 | super(payload.message);
11 | }
12 | }
13 |
14 | export interface FetcherErrorHandlerOptions {
15 | throwOverride?: boolean;
16 | }
17 |
18 | export function clientSideErrorHandler({ throwOverride }: FetcherErrorHandlerOptions): (error: Error) => boolean {
19 | return (error) => {
20 | if (error instanceof APIError) {
21 | if (error.payload.statusCode === 401 || error.payload.statusCode === 403) {
22 | return throwOverride ?? false;
23 | }
24 |
25 | if (error.payload.statusCode >= 500 && error.payload.statusCode < 600) {
26 | return throwOverride ?? true;
27 | }
28 | }
29 |
30 | return throwOverride ?? true;
31 | };
32 | }
33 |
34 | export interface FetcherOptions {
35 | body?: unknown;
36 | method: 'DELETE' | 'GET' | 'PATCH' | 'POST' | 'PUT';
37 | path: `/${string}`;
38 | }
39 |
40 | const jsonMethods = new Set(['POST', 'PUT', 'PATCH']);
41 |
42 | export default function clientSideFetcher({ path, method, body }: FetcherOptions): () => Promise {
43 | const headers: HeadersInit = {};
44 |
45 | if (body && jsonMethods.has(method)) {
46 | headers['Content-Type'] = 'application/json';
47 | }
48 |
49 | return async () => {
50 | const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL!}${path}`, {
51 | method,
52 | body: body ? JSON.stringify(body) : (body as BodyInit),
53 | credentials: 'include',
54 | headers,
55 | });
56 |
57 | if (response.ok) {
58 | return response.json();
59 | }
60 |
61 | throw new APIError(await response.json(), method);
62 | };
63 | }
64 |
--------------------------------------------------------------------------------
/apps/website/src/util/util.ts:
--------------------------------------------------------------------------------
1 | import { clsx, type ClassValue } from 'clsx';
2 | import { twMerge } from 'tailwind-merge';
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | export const retryWrapper = (retry: (retries: number, error: Error) => boolean) => {
9 | return (retries: number, error: Error) => {
10 | if (process.env.NODE_ENV === 'development') {
11 | return false;
12 | }
13 |
14 | return retry(retries, error);
15 | };
16 | };
17 |
18 | export const exponentialBackOff = (failureCount: number) => 2 ** failureCount * 1_000;
19 |
--------------------------------------------------------------------------------
/apps/website/tailwind.config.js:
--------------------------------------------------------------------------------
1 | import typographyPlugin from '@tailwindcss/typography';
2 | import tailwindAnimate from 'tailwindcss-animate';
3 |
4 | /** @type {import('tailwindcss').Config} */
5 | export default {
6 | content: ['./src/**/*.{js,ts,jsx,tsx}'],
7 | darkMode: 'class',
8 | theme: {
9 | colors: {
10 | base: {
11 | DEFAULT: '#F1F2F5',
12 | dark: '#151519',
13 | },
14 | primary: {
15 | DEFAULT: '#1d274e',
16 | dark: '#F6F6FB',
17 | },
18 | secondary: {
19 | DEFAULT: 'rgba(29, 39, 78, 0.75)',
20 | dark: '#F6F6FBB2',
21 | },
22 | accent: '#ffffff',
23 | disabled: {
24 | DEFAULT: '#1E284F80',
25 | dark: '#F5F5FC66',
26 | },
27 | on: {
28 | primary: {
29 | DEFAULT: '#1E284F40',
30 | dark: '#F4F4FD33',
31 | },
32 | secondary: {
33 | DEFAULT: 'rgba(29, 39, 78, 0.15)',
34 | dark: '#F4F4FD1A',
35 | },
36 | tertiary: {
37 | DEFAULT: '#1E284F0D',
38 | dark: '#F4F4FD0D',
39 | },
40 | },
41 | misc: {
42 | accent: '#2f8fee',
43 | danger: '#ff5052',
44 | },
45 | },
46 | extend: {},
47 | },
48 | plugins: [typographyPlugin, tailwindAnimate],
49 | };
50 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/apps/website/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "lib": ["ESNext", "DOM", "DOM.Iterable"],
5 | "jsx": "preserve",
6 | "baseUrl": ".",
7 | "outDir": "dist",
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "allowJs": true,
11 | "incremental": true,
12 | "skipLibCheck": true,
13 | "plugins": [
14 | {
15 | "name": "next"
16 | }
17 | ],
18 | "paths": {
19 | "~/*": ["./src/*"]
20 | },
21 | "resolveJsonModule": true
22 | },
23 | "include": [
24 | "src/**/*.ts",
25 | "src/**/*.tsx",
26 | "src/**/*.js",
27 | "src/**/*.jsx",
28 | "src/**/*.cjs",
29 | "src/**/*.mjs",
30 | "next-env.d.ts",
31 | ".next/types/**/*.ts"
32 | ],
33 | "exclude": ["node_modules"]
34 | }
35 |
--------------------------------------------------------------------------------
/build/caddy/Caddyfile:
--------------------------------------------------------------------------------
1 | {
2 | acme_dns cloudflare {env.CF_API_TOKEN}
3 | }
4 |
5 | api.automoderator.app {
6 | reverse_proxy * http://api:9876
7 | }
8 |
--------------------------------------------------------------------------------
/build/caddy/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM caddy:builder AS builder
2 |
3 | RUN xcaddy build --with github.com/caddy-dns/cloudflare
4 |
5 | FROM caddy:latest
6 | LABEL name "chatsift caddy"
7 |
8 | COPY --from=builder /usr/bin/caddy /usr/bin/caddy
9 | COPY ./Caddyfile /etc/caddy
10 |
--------------------------------------------------------------------------------
/compose:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 |
4 | if [ -f .env.public ]
5 | then
6 | export $(cat .env.public | xargs)
7 | fi
8 |
9 | if [ -f .env.private ]
10 | then
11 | export $(cat .env.private | xargs)
12 | fi
13 |
14 | docker compose \
15 | -f docker-compose.yml \
16 | ${@%$0}
17 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | import queryPlugin from '@tanstack/eslint-plugin-query';
2 | import common from 'eslint-config-neon/flat/common.js';
3 | import next from 'eslint-config-neon/flat/next.js';
4 | import node from 'eslint-config-neon/flat/node.js';
5 | import prettier from 'eslint-config-neon/flat/prettier.js';
6 | import react from 'eslint-config-neon/flat/react.js';
7 | import typescript from 'eslint-config-neon/flat/typescript.js';
8 | import merge from 'lodash.merge';
9 | import tseslint from 'typescript-eslint';
10 |
11 | const commonFiles = '{js,mjs,cjs,ts,mts,cts,jsx,tsx}';
12 |
13 | const commonRuleset = merge(...common, {
14 | files: [`**/*${commonFiles}`],
15 | rules: {
16 | 'no-eq-null': ['off'],
17 | eqeqeq: ['error', 'always', { null: 'ignore' }],
18 | 'jsdoc/no-undefined-types': ['off'],
19 | 'import/no-duplicates': ['off'],
20 | },
21 | });
22 |
23 | const nodeRuleset = merge(...node, { files: [`**/*${commonFiles}`] });
24 |
25 | const typeScriptRuleset = merge(...typescript, {
26 | files: [`**/*${commonFiles}`],
27 | languageOptions: {
28 | parserOptions: {
29 | warnOnUnsupportedTypeScriptVersion: false,
30 | allowAutomaticSingleRunInference: true,
31 | project: [
32 | 'tsconfig.eslint.json',
33 | 'apps/**/tsconfig.eslint.json',
34 | 'services/**/tsconfig.eslint.json',
35 | 'packages/**/tsconfig.eslint.json',
36 | ],
37 | },
38 | },
39 | rules: {
40 | '@typescript-eslint/consistent-type-definitions': [2, 'interface'],
41 | '@typescript-eslint/naming-convention': [
42 | 2,
43 | {
44 | selector: 'typeParameter',
45 | format: ['PascalCase'],
46 | custom: {
47 | regex: '^\\w{3,}',
48 | match: true,
49 | },
50 | },
51 | ],
52 | },
53 | settings: {
54 | 'import/resolver': {
55 | typescript: {
56 | project: ['tsconfig.eslint.json', 'services/*/tsconfig.eslint.json', 'packages/*/tsconfig.eslint.json'],
57 | },
58 | },
59 | },
60 | });
61 |
62 | const reactRuleset = merge(...react, {
63 | files: [`apps/**/*${commonFiles}`, `packages/ui/**/*${commonFiles}`],
64 | rules: {
65 | '@next/next/no-html-link-for-pages': 0,
66 | 'react/react-in-jsx-scope': 0,
67 | 'react/jsx-filename-extension': [1, { extensions: ['.tsx'] }],
68 | },
69 | });
70 |
71 | const nextRuleset = merge(...next, { files: [`apps/**/*${commonFiles}`] });
72 |
73 | const pluginRuleset = merge(...queryPlugin.configs['flat/recommended'], { files: [`apps/**/*${commonFiles}`] });
74 |
75 | const prettierRuleset = merge(...prettier, { files: [`**/*${commonFiles}`] });
76 |
77 | export default tseslint.config(
78 | {
79 | ignores: ['**/node_modules/', '.git/', '**/dist/', '**/coverage/', 'packages/services/core/src/db.ts'],
80 | },
81 | commonRuleset,
82 | nodeRuleset,
83 | typeScriptRuleset,
84 | reactRuleset,
85 | nextRuleset,
86 | pluginRuleset,
87 | {
88 | files: [`apps/website/**/*${commonFiles}`],
89 | rules: {
90 | 'no-restricted-globals': ['off'],
91 | 'n/prefer-global/url': ['off'],
92 | 'n/prefer-global/url-search-params': ['off'],
93 | },
94 | },
95 | prettierRuleset,
96 | );
97 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json.schemastore.org/package",
3 | "name": "chatsift",
4 | "packageManager": "yarn@4.3.1",
5 | "private": true,
6 | "version": "0.0.0",
7 | "type": "module",
8 | "workspaces": [
9 | "apps/*",
10 | "packages/*",
11 | "packages/*/*",
12 | "services/*",
13 | "services/*/*"
14 | ],
15 | "repository": {
16 | "type": "git",
17 | "url": "git+https://github.com/chatsift/chatsift.git"
18 | },
19 | "author": "didinele",
20 | "bugs": {
21 | "url": "https://github.com/chatsift/chatsift/issues"
22 | },
23 | "homepage": "https://github.com/chatsift/chatsift#readme",
24 | "scripts": {
25 | "lint": "turbo run lint && prettier --check .",
26 | "build": "turbo run build",
27 | "test": "turbo run test",
28 | "format": "prettier --write .",
29 | "postinstall": "is-ci || husky install || true",
30 | "update": "yarn upgrade-interactive",
31 | "prisma": "dotenv -e .env.private -- prisma"
32 | },
33 | "dependencies": {
34 | "prisma": "^5.22.0"
35 | },
36 | "devDependencies": {
37 | "@commitlint/cli": "^19.8.0",
38 | "@commitlint/config-angular": "^19.8.0",
39 | "@tanstack/eslint-plugin-query": "^5.74.7",
40 | "@vitest/coverage-v8": "^2.1.9",
41 | "dotenv-cli": "^7.4.4",
42 | "eslint": "^8.57.1",
43 | "eslint-config-neon": "^0.1.62",
44 | "eslint-formatter-pretty": "^6.0.1",
45 | "husky": "^9.1.7",
46 | "is-ci": "^3.0.1",
47 | "lodash.merge": "^4.6.2",
48 | "prettier": "^3.5.3",
49 | "prisma-kysely": "^1.8.0",
50 | "rimraf": "^5.0.10",
51 | "tsup": "^8.4.0",
52 | "turbo": "^2.5.2",
53 | "typescript": "^5.8.3",
54 | "typescript-eslint": "^7.18.0",
55 | "vitest": "^2.1.9"
56 | },
57 | "resolutions": {
58 | "discord-api-types": "0.37.84"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/README.md:
--------------------------------------------------------------------------------
1 | # `@chatsift/discord-utils`
2 |
3 | [](https://github.com/chatsift/chatsift/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@chatsift/discord-utils)
5 | [](https://github.com/chatsift/chatsift/actions/workflows/test.yml)
6 |
7 | Niche utilities for working with Discord's API
8 |
9 | ## Installation
10 |
11 | - `npm install @chatsift/discord-utils`
12 | - `pnpm install @chatsift/discord-utils`
13 | - `yarn add @chatsift/discord-utils`
14 |
15 | ## Contributing
16 |
17 | Please see the main [README.md](https://github.com/chatsift/chatsift) for info on how to contribute to this package or the other `@chatsift` packages.
18 |
19 | ## LICENSE
20 |
21 | This project is licensed under the GNU AGPLv3 license.
22 |
23 | It should, however, be noted that some packages are forks of other open source projects, and are therefore, sub-licensed.
24 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/discord-utils",
3 | "description": "Niche utilities for working with Discord's raw API",
4 | "version": "0.5.1",
5 | "main": "./dist/index.js",
6 | "types": "./dist/index.d.ts",
7 | "exports": {
8 | "import": "./dist/index.mjs",
9 | "require": "./dist/index.js",
10 | "types": "./dist/index.d.ts"
11 | },
12 | "directories": {
13 | "lib": "src"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "tsup && tsc",
20 | "test": "vitest run",
21 | "lint": "eslint src",
22 | "prepack": "yarn build"
23 | },
24 | "repository": {
25 | "type": "git",
26 | "url": "https://github.com/chatsift/chatsift.git",
27 | "directory": "packages/npm/discord-utils"
28 | },
29 | "bugs": {
30 | "url": "https://github.com/chatsift/chatsift/issues"
31 | },
32 | "homepage": "https://github.com/chatsift/chatsift",
33 | "devDependencies": {
34 | "@types/node": "^22.15.3",
35 | "tsup": "^8.4.0",
36 | "typescript": "^5.8.3",
37 | "vitest": "^2.1.9"
38 | },
39 | "dependencies": {
40 | "discord-api-types": "^0.38.2",
41 | "tslib": "^2.8.1"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/src/__tests__/embed.test.ts:
--------------------------------------------------------------------------------
1 | import type { APIEmbed, APIEmbedField } from 'discord-api-types/v10';
2 | import { describe, test, expect } from 'vitest';
3 | import { addFields, ellipsis, MESSAGE_LIMITS, truncateEmbed } from '../embed.js';
4 |
5 | describe('addFields', () => {
6 | test('no existing fields', () => {
7 | const embed: APIEmbed = {};
8 |
9 | const field: APIEmbedField = { name: 'foo', value: 'bar' };
10 | expect(addFields(embed, field)).toStrictEqual({ ...embed, fields: [field] });
11 | });
12 |
13 | test('existing fields', () => {
14 | const field: APIEmbedField = { name: 'foo', value: 'bar' };
15 | const embed: APIEmbed = { fields: [field] };
16 |
17 | expect(addFields(embed, field)).toStrictEqual({ ...embed, fields: [field, field] });
18 | });
19 | });
20 |
21 | describe('ellipsis', () => {
22 | test('no ellipsis', () => {
23 | expect(ellipsis('foo', 5)).toBe('foo');
24 | });
25 |
26 | test('ellipsis', () => {
27 | expect(ellipsis('foobar', 4)).toBe('f...');
28 | });
29 |
30 | test('too long for ellipsis', () => {
31 | expect(ellipsis('foo', 2)).toBe('fo');
32 | });
33 | });
34 |
35 | describe('truncateEmbed', () => {
36 | test('basic embed properties', () => {
37 | const embed: APIEmbed = {
38 | title: 'foo'.repeat(256),
39 | description: 'bar'.repeat(4_096),
40 | author: { name: 'baz'.repeat(256) },
41 | footer: { text: 'qux'.repeat(2_048) },
42 | };
43 |
44 | const truncated = truncateEmbed(embed);
45 |
46 | expect(truncated.title).toBe(ellipsis(embed.title!, MESSAGE_LIMITS.EMBEDS.TITLE));
47 | expect(truncated.description).toBe(ellipsis(embed.description!, MESSAGE_LIMITS.EMBEDS.DESCRIPTION));
48 | expect(truncated.author?.name).toBe(ellipsis(embed.author!.name, MESSAGE_LIMITS.EMBEDS.AUTHOR));
49 | expect(truncated.footer?.text).toBe(ellipsis(embed.footer!.text, MESSAGE_LIMITS.EMBEDS.FOOTER));
50 | });
51 |
52 | test('fields', () => {
53 | const embed: APIEmbed = {
54 | fields: Array.from({ length: 30 }).fill({ name: 'foo', value: 'bar' }),
55 | };
56 |
57 | expect(truncateEmbed(embed).fields).toHaveLength(MESSAGE_LIMITS.EMBEDS.FIELD_COUNT);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/src/__tests__/sortChannels.test.ts:
--------------------------------------------------------------------------------
1 | import type { APIChannel } from 'discord-api-types/v10';
2 | import { ChannelType } from 'discord-api-types/v10';
3 | import { test, expect } from 'vitest';
4 | import { sortChannels } from '../sortChannels.js';
5 |
6 | test('sorting a list of channels', () => {
7 | // Higher position than the category, but should come out on top
8 | const first = {
9 | id: '1',
10 | position: 1,
11 | type: ChannelType.GuildText,
12 | } as unknown as APIChannel;
13 |
14 | const second = {
15 | id: '0',
16 | position: 0,
17 | type: ChannelType.GuildCategory,
18 | } as unknown as APIChannel;
19 |
20 | const third = {
21 | id: '2',
22 | position: 0,
23 | type: ChannelType.GuildText,
24 | parent_id: '0',
25 | } as unknown as APIChannel;
26 |
27 | const fourth = {
28 | id: '3',
29 | position: 1,
30 | type: ChannelType.GuildText,
31 | parent_id: '0',
32 | } as unknown as APIChannel;
33 |
34 | expect(sortChannels([first, second, third, fourth])).toStrictEqual([first, second, third, fourth]);
35 | });
36 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/src/embed.ts:
--------------------------------------------------------------------------------
1 | import type { APIEmbed, APIEmbedField } from 'discord-api-types/v10';
2 |
3 | /**
4 | * Limits commonly encountered with Discord's API
5 | */
6 | export const MESSAGE_LIMITS = {
7 | /**
8 | * How long a message can be in characters
9 | */
10 | CONTENT: 4_000,
11 | /**
12 | * How many embeds can be in a message
13 | */
14 | EMBED_COUNT: 10,
15 | /**
16 | * Embed specific limits
17 | */
18 | EMBEDS: {
19 | /**
20 | * How long an embed title can be in characters
21 | */
22 | TITLE: 256,
23 | /**
24 | * How long an embed description can be in characters
25 | */
26 | DESCRIPTION: 4_096,
27 | /**
28 | * How long an embed footer can be in characters
29 | */
30 | FOOTER: 2_048,
31 | /**
32 | * How long an embed author can be in characters
33 | */
34 | AUTHOR: 256,
35 | /**
36 | * How many fields an embed can have
37 | */
38 | FIELD_COUNT: 25,
39 | /**
40 | * Field specific limits
41 | */
42 | FIELDS: {
43 | /**
44 | * How long a field name can be in characters
45 | */
46 | NAME: 256,
47 | /**
48 | * How long a field value can be in characters
49 | */
50 | VALUE: 1_024,
51 | },
52 | },
53 | } as const;
54 |
55 | /**
56 | * Adds the given fields to an embed - mutating it
57 | *
58 | * @param embed - The embed to add fields to
59 | * @param fields - The fields to add
60 | */
61 | export function addFields(embed: APIEmbed, ...fields: APIEmbedField[]): APIEmbed {
62 | (embed.fields ??= []).push(...fields);
63 | return embed;
64 | }
65 |
66 | /**
67 | * Cuts off text after the given length - appending "..." at the end
68 | *
69 | * @param text - The text to cut off
70 | * @param total - The maximum length of the text
71 | */
72 | export function ellipsis(text: string, total: number): string {
73 | if (text.length <= total) {
74 | return text;
75 | }
76 |
77 | const keep = total - 3;
78 | if (keep < 1) {
79 | return text.slice(0, total);
80 | }
81 |
82 | return `${text.slice(0, keep)}...`;
83 | }
84 |
85 | /**
86 | * Returns a fully truncated embed - safe to use with Discord's API - does not mutate the given embed
87 | *
88 | * @param embed - The embed to truncate
89 | */
90 | export function truncateEmbed(embed: APIEmbed): APIEmbed {
91 | return {
92 | ...embed,
93 | description: embed.description ? ellipsis(embed.description, MESSAGE_LIMITS.EMBEDS.DESCRIPTION) : undefined,
94 | title: embed.title ? ellipsis(embed.title, MESSAGE_LIMITS.EMBEDS.TITLE) : undefined,
95 | author: embed.author
96 | ? {
97 | ...embed.author,
98 | name: ellipsis(embed.author.name, MESSAGE_LIMITS.EMBEDS.AUTHOR),
99 | }
100 | : undefined,
101 | footer: embed.footer
102 | ? {
103 | ...embed.footer,
104 | text: ellipsis(embed.footer.text, MESSAGE_LIMITS.EMBEDS.FOOTER),
105 | }
106 | : undefined,
107 | fields: embed.fields
108 | ? embed.fields
109 | .map((field) => ({
110 | name: ellipsis(field.name, MESSAGE_LIMITS.EMBEDS.FIELDS.NAME),
111 | value: ellipsis(field.value, MESSAGE_LIMITS.EMBEDS.FIELDS.VALUE),
112 | }))
113 | .slice(0, MESSAGE_LIMITS.EMBEDS.FIELD_COUNT)
114 | : [],
115 | };
116 | }
117 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './embed.js';
2 | export * from './sortChannels.js';
3 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/src/sortChannels.ts:
--------------------------------------------------------------------------------
1 | import type { APIChannel, APIGuildCategoryChannel, APITextChannel } from 'discord-api-types/v10';
2 | import { ChannelType } from 'discord-api-types/v10';
3 |
4 | const GUILD_TEXT_TYPES = [ChannelType.GuildText, ChannelType.GuildNews, ChannelType.GuildForum];
5 |
6 | /**
7 | * Sorts an array of text and category channels - **does not support other channel types**
8 | */
9 | export function sortChannels(unsorted: APIChannel[]): (APIGuildCategoryChannel | APITextChannel)[] {
10 | const filtered = unsorted.filter(
11 | (channel): channel is APIGuildCategoryChannel | APITextChannel =>
12 | GUILD_TEXT_TYPES.includes(channel.type) || channel.type === ChannelType.GuildCategory,
13 | );
14 |
15 | // Group the channels by their category - or "top" if they aren't in one
16 | const grouped = Object.groupBy(filtered, (channel) => channel.parent_id ?? 'top');
17 |
18 | // Sort the top level channels - text channels are above category channels, otherwise use their position
19 | const sortedTopLevel = grouped.top
20 | ?.filter((channel) => !channel.parent_id)
21 | .sort((a, b) => {
22 | if (a.type === ChannelType.GuildText && b.type === ChannelType.GuildCategory) {
23 | return -1;
24 | }
25 |
26 | if (a.type === ChannelType.GuildCategory && b.type === ChannelType.GuildText) {
27 | return 1;
28 | }
29 |
30 | return a.position! - b.position!;
31 | });
32 |
33 | const channels: (APIGuildCategoryChannel | APITextChannel)[] = [];
34 | for (const top of sortedTopLevel ?? []) {
35 | channels.push(top);
36 |
37 | if (top.type === ChannelType.GuildCategory) {
38 | channels.push(...(grouped[top.id] ?? []).sort((a, b) => a.position! - b.position!));
39 | }
40 | }
41 |
42 | return channels.length ? channels : filtered.sort((a, b) => a.position! - b.position!);
43 | }
44 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "emitDeclarationOnly": true,
6 | "declaration": true,
7 | "declarationMap": true
8 | },
9 | "include": ["./src/**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { createTsupConfig } from '../../../tsup.config';
2 |
3 | export default createTsupConfig();
4 |
--------------------------------------------------------------------------------
/packages/npm/discord-utils/vitest.config.ts:
--------------------------------------------------------------------------------
1 | export { default } from '../../../vitest.config';
2 |
--------------------------------------------------------------------------------
/packages/npm/parse-relative-time/README.md:
--------------------------------------------------------------------------------
1 | # `@chatsift/parse-relative-time`
2 |
3 | [](https://github.com/chatsift/chatsift/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@chatsift/parse-relative-time)
5 | [](https://github.com/chatsift/chatsift/actions/workflows/test.yml)
6 |
7 | Relative time parser, similar to [vercel/ms](https://github.com/vercel/ms)
8 |
9 | ## Installation
10 |
11 | - `npm install @chatsift/parse-relative-time`
12 | - `pnpm install @chatsift/parse-relative-time`
13 | - `yarn add @chatsift/parse-relative-time`
14 |
15 | ## Contributing
16 |
17 | Please see the main [README.md](https://github.com/chatsift/chatsift) for info on how to contribute to this package or the other `@chatsift` packages.
18 |
19 | ## LICENSE
20 |
21 | This project is licensed under the MIT license.
22 |
23 | It should, however, be noted that some packages are forks of other open source projects, and are therefore, sub-licensed.
24 |
--------------------------------------------------------------------------------
/packages/npm/parse-relative-time/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/parse-relative-time",
3 | "description": "Relative time parser, similar to vercel/ms",
4 | "version": "0.3.1",
5 | "main": "./dist/index.js",
6 | "types": "./dist/index.d.ts",
7 | "exports": {
8 | "import": "./dist/index.mjs",
9 | "require": "./dist/index.js",
10 | "types": "./dist/index.d.ts"
11 | },
12 | "directories": {
13 | "lib": "src"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "tsup && tsc",
20 | "test": "vitest run",
21 | "lint": "eslint src",
22 | "prepack": "yarn build"
23 | },
24 | "repository": {
25 | "type": "git",
26 | "url": "https://github.com/chatsift/chatsift.git",
27 | "directory": "packages/npm/parse-relative-time"
28 | },
29 | "bugs": {
30 | "url": "https://github.com/chatsift/chatsift/issues"
31 | },
32 | "homepage": "https://github.com/chatsift/chatsift",
33 | "devDependencies": {
34 | "@types/node": "^22.15.3",
35 | "tsup": "^8.4.0",
36 | "typescript": "^5.8.3",
37 | "vitest": "^2.1.9"
38 | },
39 | "dependencies": {
40 | "tslib": "^2.8.1"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/npm/parse-relative-time/src/__tests__/index.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from 'vitest';
2 | import { parseRelativeTime } from '../index';
3 |
4 | test('empty input', () => {
5 | const result = parseRelativeTime('');
6 | expect(result).toEqual(0);
7 | });
8 |
9 | test('simple ms', () => {
10 | const result = parseRelativeTime('100ms');
11 | expect(result).toEqual(100);
12 | });
13 |
14 | test('single unit', () => {
15 | const result = parseRelativeTime('1m');
16 | expect(result).toEqual(60_000);
17 | });
18 |
19 | test('multiple units', () => {
20 | const result = parseRelativeTime('1d 2h 3m 4s 5ms');
21 | expect(result).toEqual(93_784_005);
22 | });
23 |
24 | test('multiple units without spaces', () => {
25 | const result = parseRelativeTime('1d2h3m4s5ms');
26 | expect(result).toEqual(93_784_005);
27 | });
28 |
29 | test('unknown unit', () => {
30 | expect(() => parseRelativeTime('1x')).toThrow('Unknown time unit "x"');
31 | });
32 |
33 | test('no number to parse', () => {
34 | expect(() => parseRelativeTime('s')).toThrow('There was no number associated with one of the units.');
35 | });
36 |
37 | test('empty chunk', () => {
38 | const result = parseRelativeTime('1s ');
39 | expect(result).toEqual(1_000);
40 | });
41 |
42 | test('plural unit', () => {
43 | const result = parseRelativeTime('2weeks');
44 | expect(result).toEqual(1_209_600_000);
45 | });
46 |
47 | test('alias unit', () => {
48 | const result = parseRelativeTime('1hr');
49 | expect(result).toEqual(3_600_000);
50 | });
51 |
52 | test('alias unit with plural', () => {
53 | const result = parseRelativeTime('2mos');
54 | expect(result).toEqual(4_838_400_000);
55 | });
56 |
57 | test('longer input with inconsistent spacing and implicit ms', () => {
58 | const result = parseRelativeTime('1d 2h3m 4s 5ms 6');
59 | expect(result).toEqual(94_144_005);
60 | });
61 |
--------------------------------------------------------------------------------
/packages/npm/parse-relative-time/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/packages/npm/parse-relative-time/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "emitDeclarationOnly": true,
6 | "declaration": true,
7 | "declarationMap": true
8 | },
9 | "include": ["./src/**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/npm/parse-relative-time/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { createTsupConfig } from '../../../tsup.config';
2 |
3 | export default createTsupConfig();
4 |
--------------------------------------------------------------------------------
/packages/npm/parse-relative-time/vitest.config.ts:
--------------------------------------------------------------------------------
1 | export { default } from '../../../vitest.config';
2 |
--------------------------------------------------------------------------------
/packages/npm/pino-rotate-file/README.md:
--------------------------------------------------------------------------------
1 | # `@chatsift/pino-rotate-file`
2 |
3 | [](https://github.com/chatsift/chatsift/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@chatsift/pino-rotate-file)
5 | [](https://github.com/chatsift/chatsift/actions/workflows/test.yml)
6 |
7 | Simple pino transport for rotating files
8 |
9 | ## Installation
10 |
11 | - `npm install @chatsift/pino-rotate-file`
12 | - `pnpm install @chatsift/pino-rotate-file`
13 | - `yarn add @chatsift/pino-rotate-file`
14 |
15 | ## Contributing
16 |
17 | Please see the main [README.md](https://github.com/chatsift/chatsift) for info on how to contribute to this package or the other `@chatsift` packages.
18 |
19 | ## LICENSE
20 |
21 | This project is licensed under the MIT license.
22 |
23 | It should, however, be noted that some packages are forks of other open source projects, and are therefore, sub-licensed.
24 |
--------------------------------------------------------------------------------
/packages/npm/pino-rotate-file/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/pino-rotate-file",
3 | "description": "Simple pino transport for rotating files",
4 | "version": "0.5.1",
5 | "main": "./dist/index.js",
6 | "types": "./dist/index.d.ts",
7 | "exports": {
8 | "import": "./dist/index.mjs",
9 | "require": "./dist/index.js",
10 | "types": "./dist/index.d.ts"
11 | },
12 | "directories": {
13 | "lib": "src"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "tsup && tsc",
20 | "test": "vitest run",
21 | "lint": "eslint src",
22 | "prepack": "yarn build"
23 | },
24 | "repository": {
25 | "type": "git",
26 | "url": "https://github.com/chatsift/chatsift.git",
27 | "directory": "packages/npm/pino-rotate-file"
28 | },
29 | "bugs": {
30 | "url": "https://github.com/chatsift/chatsift/issues"
31 | },
32 | "homepage": "https://github.com/chatsift/chatsift",
33 | "devDependencies": {
34 | "@types/node": "^22.15.3",
35 | "sonic-boom": "^4.2.0",
36 | "tsup": "^8.4.0",
37 | "typescript": "^5.8.3",
38 | "vitest": "^2.1.9"
39 | },
40 | "dependencies": {
41 | "pino": "^9.6.0",
42 | "pino-abstract-transport": "^1.2.0",
43 | "tslib": "^2.8.1"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/packages/npm/pino-rotate-file/src/index.ts:
--------------------------------------------------------------------------------
1 | import { once } from 'node:events';
2 | import { constants as fsConstants } from 'node:fs';
3 | import { readdir, unlink, access, mkdir } from 'node:fs/promises';
4 | import { join } from 'node:path';
5 | import build from 'pino-abstract-transport';
6 | import type { PrettyOptions } from 'pino-pretty';
7 | // eslint-disable-next-line import/no-extraneous-dependencies
8 | import { prettyFactory } from 'pino-pretty';
9 | import { SonicBoom } from 'sonic-boom';
10 |
11 | const ONE_DAY = 24 * 60 * 60 * 1_000;
12 | const DEFAULT_MAX_AGE_DAYS = 14;
13 |
14 | /**
15 | * Options for the transport
16 | */
17 | export interface PinoRotateFileOptions {
18 | dir: string;
19 | maxAgeDays?: number;
20 | mkdir?: boolean;
21 | prettyOptions?: PrettyOptions;
22 | }
23 |
24 | interface Dest {
25 | path: string;
26 | stream: SonicBoom;
27 | }
28 |
29 | function createFileName(time: number): string {
30 | return `${new Date(time).toISOString().split('T')[0]!}.log`;
31 | }
32 |
33 | async function cleanup(dir: string, maxAgeDays: number): Promise {
34 | const files = await readdir(dir);
35 | const promises: Promise[] = [];
36 |
37 | for (const file of files) {
38 | if (!file.endsWith('.log')) {
39 | continue;
40 | }
41 |
42 | const date = new Date(file.split('.')[0]!).getTime();
43 | const now = Date.now();
44 |
45 | if (now - date >= maxAgeDays * ONE_DAY) {
46 | promises.push(unlink(join(dir, file)));
47 | }
48 | }
49 |
50 | await Promise.all(promises);
51 | }
52 |
53 | async function createDest(path: string): Promise {
54 | const stream = new SonicBoom({ dest: path });
55 | await once(stream, 'ready');
56 |
57 | return {
58 | path,
59 | stream,
60 | };
61 | }
62 |
63 | async function endStream(stream: SonicBoom) {
64 | stream.end();
65 | await once(stream, 'close');
66 | }
67 |
68 | export async function pinoRotateFile(options: PinoRotateFileOptions) {
69 | const pretty = options.prettyOptions ? prettyFactory(options.prettyOptions) : null;
70 |
71 | if (options.mkdir) {
72 | try {
73 | await access(options.dir, fsConstants.F_OK);
74 | } catch {
75 | await mkdir(options.dir, { recursive: true });
76 | }
77 | }
78 |
79 | await access(options.dir, fsConstants.R_OK | fsConstants.W_OK);
80 | await cleanup(options.dir, options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS);
81 |
82 | let dest = await createDest(join(options.dir, createFileName(Date.now())));
83 | return build(
84 | async (source: AsyncIterable<{ time: number }>) => {
85 | for await (const payload of source) {
86 | const path = join(options.dir, createFileName(Date.now()));
87 | if (dest.path !== path) {
88 | await cleanup(options.dir, options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS);
89 | await endStream(dest.stream);
90 | // eslint-disable-next-line require-atomic-updates
91 | dest = await createDest(path);
92 | }
93 |
94 | const toDrain = !dest.stream.write(pretty?.(payload) ?? `${JSON.stringify(payload)}\n`);
95 | if (toDrain) {
96 | await once(dest.stream, 'drain');
97 | }
98 | }
99 | },
100 | {
101 | close: async () => {
102 | await cleanup(options.dir, options.maxAgeDays ?? DEFAULT_MAX_AGE_DAYS);
103 | await endStream(dest.stream);
104 | },
105 | },
106 | );
107 | }
108 |
109 | export default pinoRotateFile;
110 |
--------------------------------------------------------------------------------
/packages/npm/pino-rotate-file/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/packages/npm/pino-rotate-file/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "emitDeclarationOnly": true,
6 | "declaration": true,
7 | "declarationMap": true
8 | },
9 | "include": ["./src/**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/npm/pino-rotate-file/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { createTsupConfig } from '../../../tsup.config';
2 |
3 | export default createTsupConfig();
4 |
--------------------------------------------------------------------------------
/packages/npm/pino-rotate-file/vitest.config.ts:
--------------------------------------------------------------------------------
1 | export { default } from '../../../vitest.config';
2 |
--------------------------------------------------------------------------------
/packages/npm/readdir/README.md:
--------------------------------------------------------------------------------
1 | # `@chatsift/readdir`
2 |
3 | [](https://github.com/chatsift/chatsift/blob/main/LICENSE)
4 | [](https://www.npmjs.com/package/@chatsift/readdir)
5 | [](https://github.com/chatsift/chatsift/actions/workflows/test.yml)
6 |
7 | Fast, stream based recursive version of fs.readdir
8 |
9 | ## Installation
10 |
11 | - `npm install @chatsift/readdir`
12 | - `pnpm install @chatsift/readdir`
13 | - `yarn add @chatsift/readdir`
14 |
15 | ## Contributing
16 |
17 | Please see the main [README.md](https://github.com/chatsift/chatsift) for info on how to contribute to this package or the other `@chatsift` packages.
18 |
19 | ## LICENSE
20 |
21 | This project is licensed under the MIT license.
22 |
23 | It should, however, be noted that some packages are forks of other open source projects, and are therefore, sub-licensed.
24 |
--------------------------------------------------------------------------------
/packages/npm/readdir/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/readdir",
3 | "description": "Fast, stream based recursive version of fs.readdir",
4 | "version": "0.6.1",
5 | "main": "./dist/index.js",
6 | "types": "./dist/index.d.ts",
7 | "exports": {
8 | "import": "./dist/index.mjs",
9 | "require": "./dist/index.js",
10 | "types": "./dist/index.d.ts"
11 | },
12 | "directories": {
13 | "lib": "src"
14 | },
15 | "files": [
16 | "dist"
17 | ],
18 | "scripts": {
19 | "build": "tsup && tsc",
20 | "test": "vitest run",
21 | "lint": "eslint src",
22 | "prepack": "yarn build"
23 | },
24 | "repository": {
25 | "type": "git",
26 | "url": "https://github.com/chatsift/chatsift.git",
27 | "directory": "packages/npm/readdir"
28 | },
29 | "bugs": {
30 | "url": "https://github.com/chatsift/chatsift/issues"
31 | },
32 | "homepage": "https://github.com/chatsift/chatsift",
33 | "devDependencies": {
34 | "@types/node": "^22.15.3",
35 | "tsup": "^8.4.0",
36 | "typescript": "^5.8.3",
37 | "vitest": "^2.1.9"
38 | },
39 | "dependencies": {
40 | "tiny-typed-emitter": "^2.1.0",
41 | "tslib": "^2.8.1"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/npm/readdir/src/index.ts:
--------------------------------------------------------------------------------
1 | import type { RecursiveReaddirStreamOptions } from './RecursiveReaddirStream';
2 | import { RecursiveReaddirStream } from './RecursiveReaddirStream.js';
3 |
4 | export * from './RecursiveReaddirStream.js';
5 |
6 | /**
7 | * Recursively and asynchronously reads a directory, returning an iterable that can be consumed as the filesystem is traversed
8 | *
9 | * @param root - Where to start reading from
10 | * @returns An async iterable of paths
11 | * @example
12 | * ```ts
13 | * const files = await readdirRecurse('/path/to/dir');
14 | *
15 | * for await (const file of files) {
16 | * console.log(file);
17 | * }
18 | * ```
19 | */
20 | export function readdirRecurse(root: string, options?: RecursiveReaddirStreamOptions): RecursiveReaddirStream {
21 | return new RecursiveReaddirStream(root, options);
22 | }
23 |
24 | /**
25 | * Recursively and asynchronously traverses a directory, returning an array of all the paths
26 | *
27 | * @param root - Where to start reading from
28 | * @param options - Additional reading options
29 | * @returns An array of paths
30 | * @example
31 | * ```ts
32 | * const files = await readdirRecurseAsync('/path/to/dir');
33 | * console.log(files);
34 | * ```
35 | */
36 | export async function readdirRecurseAsync(root: string, options?: RecursiveReaddirStreamOptions): Promise {
37 | return new Promise((resolve, reject) => {
38 | const files: string[] = [];
39 | new RecursiveReaddirStream(root, options)
40 | .once('end', () => resolve(files))
41 | .on('data', (file) => files.push(file))
42 | .on('error', (error) => reject(error));
43 | });
44 | }
45 |
46 | /**
47 | * Recursively and asynchronously traversess multiple directories, returning an array of all the paths
48 | *
49 | * @param roots - The paths to read
50 | * @param options - Additional reading options
51 | * @returns An array of paths
52 | * @example
53 | * ```ts
54 | * const files = await readdirRecurseManyAsync(['/path/to/dir', '/other/path']);
55 | * console.log(files);
56 | * ```
57 | */
58 | export async function readdirRecurseManyAsync(
59 | roots: string[],
60 | options?: RecursiveReaddirStreamOptions,
61 | ): Promise {
62 | const results = await Promise.all(roots.map(async (root) => readdirRecurseAsync(root, options)));
63 | return results.flat();
64 | }
65 |
--------------------------------------------------------------------------------
/packages/npm/readdir/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/packages/npm/readdir/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "emitDeclarationOnly": true,
6 | "declaration": true,
7 | "declarationMap": true
8 | },
9 | "include": ["./src/**/*.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/packages/npm/readdir/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { createTsupConfig } from '../../../tsup.config';
2 |
3 | export default createTsupConfig();
4 |
--------------------------------------------------------------------------------
/packages/npm/readdir/vitest.config.ts:
--------------------------------------------------------------------------------
1 | export { default } from '../../../vitest.config';
2 |
--------------------------------------------------------------------------------
/packages/services/automoderator/.prettierignore:
--------------------------------------------------------------------------------
1 | src/db.ts
2 |
--------------------------------------------------------------------------------
/packages/services/automoderator/README.md:
--------------------------------------------------------------------------------
1 | # @automoderator/core
2 |
3 | Core for AutoModerator.
4 |
--------------------------------------------------------------------------------
/packages/services/automoderator/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@automoderator/core",
3 | "main": "./dist/index.js",
4 | "types": "./dist/index.d.ts",
5 | "private": true,
6 | "version": "0.1.0",
7 | "type": "module",
8 | "scripts": {
9 | "lint": "eslint src",
10 | "build": "tsc"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^22.15.3",
14 | "typescript": "^5.8.3"
15 | },
16 | "dependencies": {
17 | "@chatsift/discord-utils": "workspace:^",
18 | "@chatsift/service-core": "workspace:^",
19 | "@discordjs/core": "^2.1.0",
20 | "@discordjs/formatters": "^0.6.1",
21 | "@sapphire/discord-utilities": "^3.4.4",
22 | "bin-rw": "^0.1.1",
23 | "inversify": "^6.2.2",
24 | "tslib": "^2.8.1",
25 | "zod": "^3.24.3"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/services/automoderator/src/cache/GuildCacheEntity.ts:
--------------------------------------------------------------------------------
1 | import type { ICacheEntity } from '@chatsift/service-core';
2 | import { createRecipe, DataType } from 'bin-rw';
3 | import { injectable } from 'inversify';
4 |
5 | export interface CachedGuild {
6 | icon: string | null;
7 | id: string;
8 | name: string;
9 | owner_id: string;
10 | }
11 |
12 | @injectable()
13 | export class GuildCacheEntity implements ICacheEntity {
14 | public readonly TTL = 60_000;
15 |
16 | public readonly recipe = createRecipe(
17 | {
18 | icon: DataType.String,
19 | id: DataType.String,
20 | name: DataType.String,
21 | owner_id: DataType.String,
22 | },
23 | 200,
24 | );
25 |
26 | public makeKey(id: string): string {
27 | return `guild:${id}`;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/packages/services/automoderator/src/index.ts:
--------------------------------------------------------------------------------
1 | import './registrations.js';
2 |
3 | export * from './cache/GuildCacheEntity.js';
4 | export * from './notifications/INotifier.js';
5 |
6 | export * from '@chatsift/service-core';
7 |
--------------------------------------------------------------------------------
/packages/services/automoderator/src/notifications/INotifier.ts:
--------------------------------------------------------------------------------
1 | import { ModCaseKind, type CaseWithLogMessage, type ModCase } from '@chatsift/service-core';
2 | import {
3 | type APIEmbed,
4 | type APIMessage,
5 | type APIUser,
6 | type RESTPostAPIChannelMessageJSONBody,
7 | type Snowflake,
8 | } from '@discordjs/core';
9 | import { injectable } from 'inversify';
10 | import type { Selectable } from 'kysely';
11 |
12 | export interface DMUserOptions {
13 | bindToGuildId?: Snowflake;
14 | data: RESTPostAPIChannelMessageJSONBody;
15 | userId: Snowflake;
16 | }
17 |
18 | export interface LogModCaseOptions {
19 | existingMessage?: APIMessage;
20 | mod: APIUser | null;
21 | modCase: Selectable;
22 | references: CaseWithLogMessage[];
23 | target: APIUser | null;
24 | }
25 |
26 | export interface HistoryEmbedOptions {
27 | cases: CaseWithLogMessage[];
28 | target: APIUser;
29 | }
30 |
31 | @injectable()
32 | export abstract class INotifier {
33 | public readonly ACTION_COLORS_MAP = {
34 | [ModCaseKind.Warn]: 0xf47b7b,
35 | [ModCaseKind.Timeout]: 0xf47b7b,
36 | [ModCaseKind.Untimeout]: 0x5865f2,
37 | [ModCaseKind.Kick]: 0xf47b7b,
38 | [ModCaseKind.Ban]: 0xf04848,
39 | [ModCaseKind.Unban]: 0x5865f2,
40 | } as const satisfies Record;
41 |
42 | public readonly ACTION_VERBS_MAP = {
43 | [ModCaseKind.Warn]: 'warned',
44 | [ModCaseKind.Timeout]: 'timed out',
45 | [ModCaseKind.Untimeout]: 'untimed out',
46 | [ModCaseKind.Kick]: 'kicked',
47 | [ModCaseKind.Ban]: 'banned',
48 | [ModCaseKind.Unban]: 'unbanned',
49 | } as const satisfies Record;
50 |
51 | public constructor() {
52 | if (this.constructor === INotifier) {
53 | throw new Error('This class cannot be instantiated.');
54 | }
55 | }
56 |
57 | public abstract tryDMUser(options: DMUserOptions): Promise;
58 |
59 | public abstract generateModCaseEmbed(options: LogModCaseOptions): APIEmbed;
60 | public abstract logModCase(options: LogModCaseOptions): Promise;
61 | public abstract tryNotifyTargetModCase(modCase: Selectable): Promise;
62 | public abstract generateHistoryEmbed(options: HistoryEmbedOptions): APIEmbed;
63 | }
64 |
--------------------------------------------------------------------------------
/packages/services/automoderator/src/registrations.ts:
--------------------------------------------------------------------------------
1 | import { globalContainer } from '@chatsift/service-core';
2 | import { INotifier } from './notifications/INotifier.js';
3 | import { Notifier } from './notifications/Notifier.js';
4 |
5 | globalContainer.bind(INotifier).to(Notifier);
6 |
--------------------------------------------------------------------------------
/packages/services/automoderator/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/packages/services/automoderator/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "declaration": true,
6 | "declarationMap": true
7 | },
8 | "include": ["./src/**/*.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/services/core/.prettierignore:
--------------------------------------------------------------------------------
1 | src/db.ts
2 |
--------------------------------------------------------------------------------
/packages/services/core/README.md:
--------------------------------------------------------------------------------
1 | # @chatsift/service-core
2 |
3 | Core utilities and types for microservices.
4 |
--------------------------------------------------------------------------------
/packages/services/core/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/service-core",
3 | "main": "./dist/index.js",
4 | "types": "./dist/index.d.ts",
5 | "private": true,
6 | "version": "0.1.0",
7 | "type": "module",
8 | "scripts": {
9 | "lint": "eslint src",
10 | "build": "tsc"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^22.15.3",
14 | "@types/pg": "^8.11.14",
15 | "typescript": "^5.8.3"
16 | },
17 | "dependencies": {
18 | "@chatsift/discord-utils": "workspace:^",
19 | "@chatsift/pino-rotate-file": "workspace:^",
20 | "@chatsift/shared": "workspace:^",
21 | "@discordjs/brokers": "^1.0.0",
22 | "@discordjs/builders": "^1.11.1",
23 | "@discordjs/core": "^2.1.0",
24 | "@discordjs/formatters": "^0.6.1",
25 | "@discordjs/rest": "^2.5.0",
26 | "@msgpack/msgpack": "^3.1.1",
27 | "@naval-base/ms": "^3.1.0",
28 | "@sapphire/bitfield": "^1.2.4",
29 | "@sapphire/discord-utilities": "^3.4.4",
30 | "bin-rw": "^0.1.1",
31 | "coral-command": "^0.10.1",
32 | "inversify": "^6.2.2",
33 | "ioredis": "5.6.1",
34 | "kysely": "^0.27.6",
35 | "murmurhash": "^2.0.1",
36 | "pg": "^8.15.6",
37 | "pino": "^9.6.0",
38 | "pino-pretty": "^11.3.0",
39 | "tslib": "^2.8.1",
40 | "zod": "^3.24.3"
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/services/core/src/Env.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import { BotKindSchema } from '@chatsift/shared';
3 | import { SnowflakeRegex } from '@sapphire/discord-utilities';
4 | import type { Logger } from 'pino';
5 | import { z } from 'zod';
6 | import { globalContainer, INJECTION_TOKENS } from './container.js';
7 |
8 | const envSchema = z.object({
9 | // General config
10 | NODE_ENV: z.enum(['dev', 'prod']).default('prod'),
11 | LOGS_DIR: z.string(),
12 | ROOT_DOMAIN: z.string(),
13 |
14 | // DB
15 | POSTGRES_HOST: z.string(),
16 | POSTGRES_PORT: z.string().pipe(z.coerce.number()),
17 | POSTGRES_USER: z.string(),
18 | POSTGRES_PASSWORD: z.string(),
19 | POSTGRES_DATABASE: z.string(),
20 |
21 | // Redis
22 | REDIS_URL: z.string().url(),
23 |
24 | // Bot config
25 | ADMINS: z
26 | .string()
27 | .transform((value) => value.split(', '))
28 | .pipe(z.array(z.string().regex(SnowflakeRegex)))
29 | .transform((value) => new Set(value)),
30 |
31 | AUTOMODERATOR_DISCORD_TOKEN: z.string(),
32 | AUTOMODERATOR_DISCORD_CLIENT_ID: z.string().regex(SnowflakeRegex),
33 | AUTOMODERATOR_GATEWAY_URL: z.string().url(),
34 | AUTOMODERATOR_PROXY_URL: z.string().url(),
35 |
36 | // API
37 | API_PORT: z.string().pipe(z.coerce.number()),
38 | PUBLIC_API_URL_DEV: z.string().url(),
39 | PUBLIC_API_URL_PROD: z.string().url(),
40 | SECRET_SIGNING_KEY: z.string().length(44),
41 | OAUTH_DISCORD_CLIENT_ID: z.string().regex(SnowflakeRegex),
42 | OAUTH_DISCORD_CLIENT_SECRET: z.string(),
43 | CORS: z.string().transform((value, ctx) => {
44 | try {
45 | return new RegExp(value);
46 | } catch {
47 | ctx.addIssue({
48 | code: z.ZodIssueCode.custom,
49 | message: 'Not a valid regular expression',
50 | });
51 | }
52 | }),
53 | ALLOWED_API_ORIGINS: z
54 | .string()
55 | .transform((value) => value.split(','))
56 | .pipe(z.array(z.string().url()).min(1)),
57 |
58 | // Bot service specific
59 | BOT: BotKindSchema.optional(),
60 | });
61 |
62 | export const Env = envSchema.parse(process.env);
63 |
64 | export interface BotCredentials {
65 | clientId: string;
66 | gatewayURL: string;
67 | proxyURL: string;
68 | token: string;
69 | }
70 |
71 | let loggedForCredentials = false;
72 |
73 | export function credentialsForCurrentBot(): BotCredentials {
74 | const logger = globalContainer.get(INJECTION_TOKENS.logger);
75 |
76 | if (!loggedForCredentials) {
77 | logger.info(`Retrieving credentials for bot ${Env.BOT}`);
78 | loggedForCredentials = true;
79 | }
80 |
81 | switch (Env.BOT) {
82 | case 'automoderator': {
83 | return {
84 | token: Env.AUTOMODERATOR_DISCORD_TOKEN,
85 | clientId: Env.AUTOMODERATOR_DISCORD_CLIENT_ID,
86 | proxyURL: Env.AUTOMODERATOR_PROXY_URL,
87 | gatewayURL: Env.AUTOMODERATOR_GATEWAY_URL,
88 | };
89 | }
90 |
91 | default: {
92 | throw new Error('process.env.BOT is not set or is invalid');
93 | }
94 | }
95 | }
96 |
97 | export const API_URL = Env.NODE_ENV === 'dev' ? Env.PUBLIC_API_URL_DEV : Env.PUBLIC_API_URL_PROD;
98 |
--------------------------------------------------------------------------------
/packages/services/core/src/README.md:
--------------------------------------------------------------------------------
1 | # src
2 |
3 | Please do not modify the [`db.ts` file](./db.ts) in this directory. It is automatically managed by Prisma+Kysely.
4 | Use `yarn prisma generate` in the root directory to reflect schema changes.
5 |
6 | A couple of things to note about this codebase:
7 |
8 | - There are areas where we leverage sort of out-of-pattern factory singletons. This is preferred over just binding
9 | a proper factory in the container because it:
10 | 1. Allows us to rely on implicit resolution of the factory without the need to `@inject()` a symbol.
11 | 2. Is less boilerplate overall.
12 | - One thing to note about this approach is that we do theoretically risk a footgun here, but because
13 | those factory classes are so incredibly simple and should always return `IX` (interfaces), it should never
14 | ever be an issue.
15 | - When we use `snake_case` in our own types, it's generally because those properties are directly mapped from Discord's
16 | API.
17 | - Generally, we only use `constructor(private readonly foo: Foo)` to signal a dependency being injected,
18 | other sorts of parameters should be taken in as usual and assigned in the constructor.
19 |
--------------------------------------------------------------------------------
/packages/services/core/src/broker-types/gateway.ts:
--------------------------------------------------------------------------------
1 | import type { GatewayDispatchEvents, GatewayDispatchPayload, GatewaySendPayload } from '@discordjs/core';
2 |
3 | type _DiscordGatewayEventsMap = {
4 | [K in GatewayDispatchEvents]: GatewayDispatchPayload & {
5 | t: K;
6 | };
7 | };
8 |
9 | export type DiscordGatewayEventsMap = {
10 | [K in keyof _DiscordGatewayEventsMap]: _DiscordGatewayEventsMap[K]['d'];
11 | } & {
12 | send: {
13 | payload: GatewaySendPayload;
14 | shardId?: number;
15 | };
16 | };
17 |
--------------------------------------------------------------------------------
/packages/services/core/src/cache/CacheFactory.ts:
--------------------------------------------------------------------------------
1 | import { inject, injectable } from 'inversify';
2 | import { Redis } from 'ioredis';
3 | import { INJECTION_TOKENS } from '../container.js';
4 | import type { ICache } from './ICache.js';
5 | import type { ICacheEntity } from './ICacheEntity.js';
6 | import { RedisCache } from './RedisCache.js';
7 |
8 | /**
9 | * @remarks
10 | * Since this is a singleton factroy, we "cache our caches" in a WeakMap to avoid additional computation on subsequent calls.
11 | */
12 | @injectable()
13 | export class CacheFactory {
14 | private readonly caches = new WeakMap, ICache>();
15 |
16 | public constructor(@inject(INJECTION_TOKENS.redis) private readonly redis: Redis) {}
17 |
18 | public build(entity: ICacheEntity): ICache {
19 | if (this.caches.has(entity)) {
20 | return this.caches.get(entity)! as ICache;
21 | }
22 |
23 | const cache = new RedisCache(this.redis, entity);
24 | this.caches.set(entity, cache);
25 |
26 | return cache;
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/packages/services/core/src/cache/ICache.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Responsible for caching data in Redis.
3 | */
4 | export interface ICache {
5 | delete(id: string): Promise;
6 | get(id: string): Promise;
7 | getOld(id: string): Promise;
8 | has(id: string): Promise;
9 | set(id: string, value: ValueType): Promise;
10 | }
11 |
--------------------------------------------------------------------------------
/packages/services/core/src/cache/ICacheEntity.ts:
--------------------------------------------------------------------------------
1 | import type { Recipe } from 'bin-rw';
2 |
3 | /**
4 | * Responsible for defining the behavior of a given entity when its being cached.
5 | */
6 | export interface ICacheEntity {
7 | /**
8 | * How long an entity of this type should remain in the cache without any operations being performed on it.
9 | */
10 | readonly TTL: number;
11 | /**
12 | * Generates a redis key for this entity.
13 | */
14 | makeKey(id: string): string;
15 | /**
16 | * Recipe for encoding and decoding TData.
17 | */
18 | readonly recipe: Recipe;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/services/core/src/cache/README.md:
--------------------------------------------------------------------------------
1 | # cache
2 |
3 | This is where we deal with Discord data caching. A couple of things to note:
4 |
5 | - we use our own encoding format for cache to make encoding/decoding and space usage as efficient as possible.
6 | Refer to the [`binary-encoding` directory](../binary-encoding)
7 | for more information.
8 | - `Cache` is deliberately not decorated with `@injectable()`, because we have a rather dynamic dependency
9 | of a `CacheEntity`, responsible for specific encoding/decoding ops, defining the cache key and TTL of the
10 | data and so on. As such, we use a factory singleton to create `Cache` instances.
11 |
--------------------------------------------------------------------------------
/packages/services/core/src/cache/RedisCache.ts:
--------------------------------------------------------------------------------
1 | import type Redis from 'ioredis';
2 | import type { ICache } from './ICache.js';
3 | import type { ICacheEntity } from './ICacheEntity.js';
4 |
5 | /**
6 | * @remarks
7 | * This class is deliberately not an `@injectable()`, refer to the README for more information on the pattern
8 | * being used.
9 | */
10 | export class RedisCache implements ICache {
11 | public constructor(
12 | private readonly redis: Redis,
13 | private readonly entity: ICacheEntity,
14 | ) {}
15 |
16 | public async has(id: string): Promise {
17 | return Boolean(await this.redis.exists(this.entity.makeKey(id)));
18 | }
19 |
20 | public async get(id: string): Promise {
21 | const key = this.entity.makeKey(id);
22 | const raw = await this.redis.getBuffer(key);
23 |
24 | if (!raw) {
25 | return null;
26 | }
27 |
28 | await this.redis.pexpire(key, this.entity.TTL);
29 | return this.entity.recipe.decode(raw);
30 | }
31 |
32 | public async getOld(id: string): Promise {
33 | const key = `old:${this.entity.makeKey(id)}`;
34 | const raw = await this.redis.getBuffer(key);
35 |
36 | if (!raw) {
37 | return null;
38 | }
39 |
40 | await this.redis.pexpire(key, this.entity.TTL);
41 | return this.entity.recipe.decode(raw);
42 | }
43 |
44 | public async set(id: string, value: ValueType): Promise {
45 | const key = this.entity.makeKey(id);
46 | if (await this.redis.exists(key)) {
47 | await this.redis.rename(key, `old:${key}`);
48 | await this.redis.pexpire(`old:${key}`, this.entity.TTL);
49 | }
50 |
51 | const raw = this.entity.recipe.encode(value);
52 | await this.redis.set(key, raw, 'PX', this.entity.TTL);
53 | }
54 |
55 | public async delete(id: string) {
56 | const key = this.entity.makeKey(id);
57 | await this.redis.del(key, `old:${key}`);
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/packages/services/core/src/command-framework/handlers/README.md:
--------------------------------------------------------------------------------
1 | # command-framework/handlers
2 |
3 | This folder contains some useful bot-agnostic handlers that we can use in any bot in the monorepo.
4 |
--------------------------------------------------------------------------------
/packages/services/core/src/command-framework/handlers/dev.ts:
--------------------------------------------------------------------------------
1 | import { InteractionContextType, type APIApplicationCommandInteraction } from '@discordjs/core';
2 | import { ActionKind, HandlerStep, type InteractionHandler as CoralInteractionHandler } from 'coral-command';
3 | import { injectable } from 'inversify';
4 | import { Env } from '../../Env.js';
5 | import { type HandlerModule, ICommandHandler } from '../ICommandHandler.js';
6 |
7 | @injectable()
8 | export default class DevHandler implements HandlerModule {
9 | public constructor(private readonly handler: ICommandHandler) {}
10 |
11 | public register() {
12 | this.handler.register({
13 | interactions: [
14 | {
15 | name: 'deploy',
16 | description: 'Deploy commands',
17 | options: [],
18 | contexts: [InteractionContextType.BotDM],
19 | },
20 | ],
21 | applicationCommands: [['deploy:none:none', this.handleDeploy.bind(this)]],
22 | });
23 | }
24 |
25 | public async *handleDeploy(interaction: APIApplicationCommandInteraction): CoralInteractionHandler {
26 | if (!interaction.user) {
27 | throw new Error('Command /deploy was ran in non-dm.');
28 | }
29 |
30 | yield* HandlerStep.from({
31 | action: ActionKind.EnsureDeferReply,
32 | options: {},
33 | });
34 |
35 | if (!Env.ADMINS.has(interaction.user.id)) {
36 | yield* HandlerStep.from({
37 | action: ActionKind.Reply,
38 | options: {
39 | content: 'You are not authorized to use this command',
40 | },
41 | });
42 | return;
43 | }
44 |
45 | await this.handler.deployCommands();
46 | yield* HandlerStep.from({
47 | action: ActionKind.Reply,
48 | options: {
49 | content: 'Successfully deployed commands',
50 | },
51 | });
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/packages/services/core/src/container.ts:
--------------------------------------------------------------------------------
1 | import { Container } from 'inversify';
2 |
3 | export const globalContainer = new Container({
4 | autoBindInjectable: true,
5 | defaultScope: 'Singleton',
6 | });
7 |
8 | export const INJECTION_TOKENS = {
9 | logger: Symbol('logger instance'),
10 | redis: Symbol('redis instance'),
11 | env: Symbol('env'),
12 | } as const;
13 |
--------------------------------------------------------------------------------
/packages/services/core/src/database/IDatabase.ts:
--------------------------------------------------------------------------------
1 | import type { Snowflake } from '@discordjs/core';
2 | import { injectable } from 'inversify';
3 | import type { Selectable } from 'kysely';
4 | import type {
5 | DiscordOAuth2User,
6 | Experiment,
7 | ExperimentOverride,
8 | Incident,
9 | LogWebhook,
10 | LogWebhookKind,
11 | ModCase,
12 | ModCaseKind,
13 | ModCaseLogMessage,
14 | } from '../db.js';
15 |
16 | export type ExperimentWithOverrides = Selectable & { overrides: Selectable[] };
17 |
18 | export interface CreateModCaseOptions {
19 | guildId: string;
20 | kind: ModCaseKind;
21 | modId: string;
22 | reason: string;
23 | references: number[];
24 | targetId: string;
25 | }
26 |
27 | export interface GetRecentModCasesAgainstOptions {
28 | guildId: string;
29 | targetId: string;
30 | }
31 |
32 | export interface GetModCasesAgainstOptions extends GetRecentModCasesAgainstOptions {
33 | page: number;
34 | }
35 |
36 | export type CaseWithLogMessage = Selectable & { logMessage: Selectable | null };
37 |
38 | export type UpdateModCaseOptions = Partial, 'id'>> & { references?: number[] };
39 |
40 | /**
41 | * Abstraction over all database interactions
42 | */
43 | @injectable()
44 | export abstract class IDatabase {
45 | public constructor() {
46 | if (this.constructor === IDatabase) {
47 | throw new Error('This class cannot be instantiated.');
48 | }
49 | }
50 |
51 | public abstract getExperiments(): Promise;
52 | public abstract createIncident(error: Error, guildId?: string): Promise>;
53 |
54 | public abstract getModCase(caseId: number): Promise | undefined>;
55 | public abstract getModCaseReferences(caseId: number): Promise;
56 | public abstract getModCaseBulk(caseIds: number[]): Promise;
57 | public abstract getModCasesAgainst(options: GetModCasesAgainstOptions): Promise;
58 | public abstract getRecentModCasesAgainst(options: GetRecentModCasesAgainstOptions): Promise;
59 | public abstract createModCase(options: CreateModCaseOptions): Promise>;
60 | public abstract updateModCase(caseId: number, data: UpdateModCaseOptions): Promise;
61 | public abstract deleteModCase(caseId: number): Promise;
62 |
63 | public abstract upsertModCaseLogMessage(
64 | options: Selectable,
65 | ): Promise>;
66 |
67 | public abstract getLogWebhook(guildId: string, kind: LogWebhookKind): Promise | undefined>;
68 |
69 | public abstract getDiscordOAuth2User(userId: Snowflake): Promise | undefined>;
70 | public abstract upsertDiscordOAuth2User(user: Selectable): Promise>;
71 | }
72 |
--------------------------------------------------------------------------------
/packages/services/core/src/database/README.md:
--------------------------------------------------------------------------------
1 | # applicationData
2 |
3 | Database abstraction layer for the application.
4 |
--------------------------------------------------------------------------------
/packages/services/core/src/db.ts:
--------------------------------------------------------------------------------
1 | import type { ColumnType } from "kysely";
2 | export type Generated = T extends ColumnType
3 | ? ColumnType
4 | : ColumnType;
5 | export type Timestamp = ColumnType;
6 |
7 | export const ModCaseKind = {
8 | Warn: "Warn",
9 | Timeout: "Timeout",
10 | Kick: "Kick",
11 | Ban: "Ban",
12 | Untimeout: "Untimeout",
13 | Unban: "Unban"
14 | } as const;
15 | export type ModCaseKind = (typeof ModCaseKind)[keyof typeof ModCaseKind];
16 | export const LogWebhookKind = {
17 | Mod: "Mod"
18 | } as const;
19 | export type LogWebhookKind = (typeof LogWebhookKind)[keyof typeof LogWebhookKind];
20 | export type CaseReference = {
21 | referencedById: number;
22 | referencesId: number;
23 | };
24 | export type DiscordOAuth2User = {
25 | id: string;
26 | accessToken: string;
27 | refreshToken: string;
28 | expiresAt: Timestamp;
29 | };
30 | export type Experiment = {
31 | name: string;
32 | createdAt: Generated;
33 | updatedAt: Timestamp | null;
34 | rangeStart: number;
35 | rangeEnd: number;
36 | };
37 | export type ExperimentOverride = {
38 | id: Generated;
39 | guildId: string;
40 | experimentName: string;
41 | };
42 | export type Incident = {
43 | id: Generated;
44 | stack: string;
45 | causeStack: string | null;
46 | guildId: string | null;
47 | createdAt: Generated;
48 | resolved: Generated;
49 | };
50 | export type LogWebhook = {
51 | id: Generated;
52 | guildId: string;
53 | webhookId: string;
54 | webhookToken: string;
55 | threadId: string | null;
56 | kind: LogWebhookKind;
57 | };
58 | export type ModCase = {
59 | id: Generated;
60 | guildId: string;
61 | kind: ModCaseKind;
62 | createdAt: Generated;
63 | reason: string;
64 | modId: string;
65 | targetId: string;
66 | };
67 | export type ModCaseLogMessage = {
68 | caseId: number;
69 | messageId: string;
70 | channelId: string;
71 | };
72 | export type DB = {
73 | CaseReference: CaseReference;
74 | DiscordOAuth2User: DiscordOAuth2User;
75 | Experiment: Experiment;
76 | ExperimentOverride: ExperimentOverride;
77 | Incident: Incident;
78 | LogWebhook: LogWebhook;
79 | ModCase: ModCase;
80 | ModCaseLogMessage: ModCaseLogMessage;
81 | };
82 |
--------------------------------------------------------------------------------
/packages/services/core/src/experiments/ExperimentHandler.ts:
--------------------------------------------------------------------------------
1 | import { setInterval } from 'node:timers';
2 | import { inject, injectable } from 'inversify';
3 | import murmurhash from 'murmurhash';
4 | import type { Logger } from 'pino';
5 | import { INJECTION_TOKENS } from '../container.js';
6 | import { IDatabase, type ExperimentWithOverrides } from '../database/IDatabase.js';
7 | import { IExperimentHandler } from './IExperimentHandler.js';
8 |
9 | @injectable()
10 | export class ExperimentHandler extends IExperimentHandler {
11 | readonly #experimentCache = new Map();
12 |
13 | public constructor(
14 | private readonly database: IDatabase,
15 | @inject(INJECTION_TOKENS.logger) private readonly logger: Logger,
16 | ) {
17 | super();
18 |
19 | void this.poll();
20 | setInterval(async () => this.poll(), 180_000).unref();
21 | }
22 |
23 | public guildIsInExperiment(guildId: string, experimentName: string): boolean {
24 | const experiment = this.#experimentCache.get(experimentName);
25 | if (!experiment) {
26 | this.logger.warn(
27 | { guildId, experimentName },
28 | 'Ran experiment check for an unknown experiment. Perhaps cache is out of date?',
29 | );
30 |
31 | return false;
32 | }
33 |
34 | const isOverriden = experiment.overrides.some((experiment) => experiment.guildId === guildId);
35 | if (isOverriden) {
36 | return true;
37 | }
38 |
39 | const hash = this.computeExperimentHash(experimentName, guildId);
40 | return hash >= experiment.rangeStart && hash < experiment.rangeEnd;
41 | }
42 |
43 | private async poll(): Promise {
44 | const experiments = await this.database.getExperiments();
45 |
46 | this.#experimentCache.clear();
47 | for (const experiment of experiments) {
48 | this.#experimentCache.set(experiment.name, experiment);
49 | }
50 | }
51 |
52 | private computeExperimentHash(name: string, guildId: string): number {
53 | return murmurhash.v3(`${name}:${guildId}`) % 1e4;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/packages/services/core/src/experiments/IExperimentHandler.ts:
--------------------------------------------------------------------------------
1 | import { injectable } from 'inversify';
2 |
3 | /**
4 | * Deals with "experiments" in the app. Those can serve as complete feature flags or just as a way to progressively
5 | * roll out a feature.
6 | */
7 | @injectable()
8 | export abstract class IExperimentHandler {
9 | public constructor() {
10 | if (this.constructor === IExperimentHandler) {
11 | throw new Error('This class cannot be instantiated.');
12 | }
13 | }
14 |
15 | /**
16 | * Checks if a given guild is in a given experiment.
17 | */
18 | public abstract guildIsInExperiment(guildId: string, experimentName: string): boolean;
19 | }
20 |
--------------------------------------------------------------------------------
/packages/services/core/src/experiments/README.md:
--------------------------------------------------------------------------------
1 | # experiments
2 |
3 | This directory contains handling of "experiments" across the entire stack. This is how we run on "every code push hits prod".
4 |
5 | We generate a "hash" (value between 0 and 9999) from the experiment name and the guild ID. The database holds experiment
6 | configuration data (ranges and overrides), which is how we determine if a guild is part of an experiment with minimal database hits.
7 |
8 | Note: Naming convention for experiments is EXPERIMENT*NAME*[YYYY]\_[MM]
9 |
10 | Note: Experiment data is re-polled ever 3 minutes.
11 |
--------------------------------------------------------------------------------
/packages/services/core/src/index.ts:
--------------------------------------------------------------------------------
1 | import './registrations.js';
2 |
3 | export * from './broker-types/gateway.js';
4 |
5 | // Deliberately don't export impl
6 | export * from './cache/ICacheEntity.js';
7 |
8 | // Deliberately don't export impl
9 | export * from './cache/CacheFactory.js';
10 | export * from './cache/ICache.js';
11 |
12 | // Here we actually do, because unlike other parts of the codebases, we don't rely on the WHOLE stack using the same impl
13 | // every service can decide what to do.
14 | export * from './command-framework/CoralCommandHandler.js';
15 | export * from './command-framework/ICommandHandler.js';
16 |
17 | // Deliberately don't export impl
18 | export * from './database/IDatabase.js';
19 |
20 | // Deliberately don't export impl
21 | export * from './experiments/IExperimentHandler.js';
22 |
23 | export * from './util/DependencyManager.js';
24 | export * from './util/encode.js';
25 | export * from './util/setupCrashLogs.js';
26 |
27 | export * from './container.js';
28 | export * from './db.js';
29 | export * from './Env.js';
30 |
31 | export * from '@chatsift/shared';
32 |
--------------------------------------------------------------------------------
/packages/services/core/src/registrations.ts:
--------------------------------------------------------------------------------
1 | import { globalContainer } from './container.js';
2 | import { IDatabase } from './database/IDatabase.js';
3 | import { KyselyPostgresDatabase } from './database/KyselyPostgresDatabase.js';
4 | import { ExperimentHandler } from './experiments/ExperimentHandler.js';
5 | import { IExperimentHandler } from './experiments/IExperimentHandler.js';
6 |
7 | globalContainer.bind(IDatabase).to(KyselyPostgresDatabase);
8 | globalContainer.bind(IExperimentHandler).to(ExperimentHandler);
9 |
--------------------------------------------------------------------------------
/packages/services/core/src/util/DependencyManager.ts:
--------------------------------------------------------------------------------
1 | import type { PinoRotateFileOptions } from '@chatsift/pino-rotate-file';
2 | import { API } from '@discordjs/core';
3 | import { DefaultRestOptions, REST } from '@discordjs/rest';
4 | import { injectable } from 'inversify';
5 | import { Redis } from 'ioredis';
6 | import type { Logger, TransportTargetOptions } from 'pino';
7 | import createPinoLogger, { pino } from 'pino';
8 | import { credentialsForCurrentBot, Env } from '../Env.js';
9 | import { INJECTION_TOKENS, globalContainer } from '../container.js';
10 |
11 | @injectable()
12 | /**
13 | * Helper class to abstract away boilerplate present at the start of every service.
14 | *
15 | * @remarks
16 | * There are services that run I/O from the get-go (e.g. the database), that need to be explicitly
17 | * opted-into by each service, which is what the methods in this class are for.
18 | *
19 | * Additionally, this class is responsible for binding certain structures, that cannot be directly resolved
20 | * (e.g. factories, things abstracted away by interfaces)
21 | */
22 | export class DependencyManager {
23 | public registerRedis(): Redis {
24 | const redis = new Redis(Env.REDIS_URL);
25 | globalContainer.bind(INJECTION_TOKENS.redis).toConstantValue(redis);
26 | return redis;
27 | }
28 |
29 | public registerApi(withToken = true): API {
30 | const credentials = withToken ? credentialsForCurrentBot() : null;
31 |
32 | const rest = new REST({ api: credentials ? `${credentials.proxyURL}/api` : DefaultRestOptions.api, version: '10' });
33 |
34 | if (credentials) {
35 | rest.setToken(credentials.token);
36 | }
37 |
38 | const api = new API(rest);
39 |
40 | globalContainer.bind(API).toConstantValue(api);
41 | return api;
42 | }
43 |
44 | public registerLogger(service: string): Logger {
45 | const targets: TransportTargetOptions[] = [
46 | {
47 | target: 'pino/file',
48 | level: 'trace',
49 | options: {
50 | destination: 1, // stdout
51 | },
52 | },
53 | ];
54 |
55 | if (Env.NODE_ENV === 'prod') {
56 | const options: PinoRotateFileOptions = {
57 | dir: Env.LOGS_DIR,
58 | mkdir: false,
59 | maxAgeDays: 14,
60 | prettyOptions: {
61 | translateTime: 'SYS:standard',
62 | levelKey: 'levelNum',
63 | },
64 | };
65 |
66 | targets.push({
67 | target: '@chatsift/pino-rotate-file',
68 | level: 'trace',
69 | options,
70 | });
71 | }
72 |
73 | const transport = pino.transport({
74 | targets,
75 | level: 'trace',
76 | });
77 |
78 | const logger = createPinoLogger(
79 | {
80 | level: 'trace',
81 | name: service,
82 | timestamp: pino.stdTimeFunctions.isoTime,
83 | formatters: {
84 | level: (levelLabel, level) => ({ level, levelLabel }),
85 | },
86 | },
87 | transport,
88 | );
89 |
90 | globalContainer.bind(INJECTION_TOKENS.logger).toConstantValue(logger);
91 | return logger;
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/packages/services/core/src/util/README.md:
--------------------------------------------------------------------------------
1 | # util
2 |
3 | Contains general purpose utility functions and types with no dependencies or complexity attached.
4 |
--------------------------------------------------------------------------------
/packages/services/core/src/util/encode.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file implements a `msgpack` extension codec that allows us to send `bigint` values over the wire.
3 | *
4 | * We make use of `msgpack` encoding for broker messages.
5 | */
6 |
7 | import { Buffer } from 'node:buffer';
8 | import { ExtensionCodec, encode as msgpackEncode, decode as msgpackDecode } from '@msgpack/msgpack';
9 |
10 | const extensionCodec = new ExtensionCodec();
11 | extensionCodec.register({
12 | type: 0,
13 | encode: (input: unknown) => {
14 | if (typeof input === 'bigint') {
15 | return msgpackEncode(input.toString());
16 | }
17 |
18 | return null;
19 | },
20 | decode: (data: Uint8Array) => BigInt(msgpackDecode(data) as string),
21 | });
22 |
23 | export function encode(data: unknown): Buffer {
24 | const encoded = msgpackEncode(data, { extensionCodec });
25 | return Buffer.from(encoded.buffer, encoded.byteOffset, encoded.byteLength);
26 | }
27 |
28 | export function decode(data: Buffer): unknown {
29 | return msgpackDecode(data, { extensionCodec });
30 | }
31 |
--------------------------------------------------------------------------------
/packages/services/core/src/util/setupCrashLogs.ts:
--------------------------------------------------------------------------------
1 | import process from 'node:process';
2 | import type { Logger } from 'pino';
3 | import { INJECTION_TOKENS, globalContainer } from '../container.js';
4 |
5 | export function setupCrashLogs() {
6 | const logger = globalContainer.get(INJECTION_TOKENS.logger);
7 |
8 | process.on('uncaughtExceptionMonitor', (err, origin) => {
9 | logger.fatal({ err, origin }, 'Uncaught exception. Likely a hard crash');
10 | });
11 | }
12 |
--------------------------------------------------------------------------------
/packages/services/core/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/packages/services/core/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "declaration": true,
6 | "declarationMap": true
7 | },
8 | "include": ["./src/**/*.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/packages/shared/.prettierignore:
--------------------------------------------------------------------------------
1 | src/db.ts
2 |
--------------------------------------------------------------------------------
/packages/shared/README.md:
--------------------------------------------------------------------------------
1 | # @chatsift/shared
2 |
3 | For interactions between bots, their services, the ChatSift API, and the frontend.
4 |
--------------------------------------------------------------------------------
/packages/shared/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/shared",
3 | "main": "./dist/index.js",
4 | "types": "./dist/index.d.ts",
5 | "private": true,
6 | "version": "0.1.0",
7 | "type": "module",
8 | "scripts": {
9 | "lint": "eslint src",
10 | "build": "tsc"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^22.15.3",
14 | "typescript": "^5.8.3"
15 | },
16 | "dependencies": {
17 | "@sapphire/bitfield": "^1.2.4",
18 | "@sapphire/discord-utilities": "^3.4.4",
19 | "discord-api-types": "^0.38.2",
20 | "tslib": "^2.8.1",
21 | "zod": "^3.24.3"
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/packages/shared/src/README.md:
--------------------------------------------------------------------------------
1 | # src
2 |
3 | Please do not modify the [`db.ts` file](./db.ts) in this directory. It is automatically managed by Prisma+Kysely.
4 | Use `yarn prisma generate` in the root directory to reflect schema changes.
5 |
6 | A couple of things to note about this codebase:
7 |
8 | - There are areas where we leverage sort of out-of-pattern factory singletons. This is preferred over just binding
9 | a proper factory in the container because it:
10 | 1. Allows us to rely on implicit resolution of the factory without the need to `@inject()` a symbol.
11 | 2. Is less boilerplate overall.
12 | - One thing to note about this approach is that we do theoretically risk a footgun here, but because
13 | those factory classes are so incredibly simple and should always return `IX` (interfaces), it should never
14 | ever be an issue.
15 | - When we use `snake_case` in our own types, it's generally because those properties are directly mapped from Discord's
16 | API.
17 | - Generally, we only use `constructor(private readonly foo: Foo)` to signal a dependency being injected,
18 | other sorts of parameters should be taken in as usual and assigned in the constructor.
19 |
--------------------------------------------------------------------------------
/packages/shared/src/api/auth/auth.ts:
--------------------------------------------------------------------------------
1 | import { SnowflakeRegex } from '@sapphire/discord-utilities';
2 | import { z } from 'zod';
3 | import { BotKindSchema } from '../bots/schema.js';
4 |
5 | export const UserMeGuildSchema = z
6 | .object({
7 | id: z.string().regex(SnowflakeRegex),
8 | icon: z.string().nullable(),
9 | name: z.string(),
10 | bots: z.array(BotKindSchema),
11 | })
12 | .strict();
13 |
14 | export const UserMeSchema = z
15 | .object({
16 | avatar: z.string().nullable(),
17 | username: z.string(),
18 | id: z.string().regex(SnowflakeRegex),
19 | guilds: z.array(UserMeGuildSchema),
20 | })
21 | .strict();
22 |
23 | export type UserMeGuild = z.infer;
24 | export type UserMe = z.infer;
25 |
--------------------------------------------------------------------------------
/packages/shared/src/api/bots/schema.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const BOTS = ['automoderator'] as const;
4 | export const GeneralModuleKind = ['boolean'] as const;
5 |
6 | export const BotKindSchema = z.enum(BOTS);
7 | export const GeneralModuleKindSchema = z.enum(GeneralModuleKind);
8 |
9 | export const MetaSchema = z
10 | .object({
11 | label: z.string(),
12 | description: z.string().nullable(),
13 | })
14 | .strict();
15 |
16 | export const GeneralConfigModuleOptionsSchema = z
17 | .object({
18 | meta: MetaSchema,
19 | kind: GeneralModuleKindSchema,
20 | })
21 | .strict();
22 |
23 | export const GeneralConfigModuleSchema = z
24 | .object({
25 | meta: MetaSchema,
26 | kind: z.literal('general'),
27 | options: z.array(GeneralConfigModuleOptionsSchema).min(1),
28 | })
29 | .strict();
30 |
31 | export const WebhookConfigModuleOptionsSchema = z
32 | .object({
33 | meta: MetaSchema,
34 | })
35 | .strict();
36 |
37 | export const WebhookConfigModuleSchema = z
38 | .object({
39 | meta: MetaSchema,
40 | kind: z.literal('webhook'),
41 | options: z.array(WebhookConfigModuleOptionsSchema).min(1),
42 | })
43 | .strict();
44 |
45 | export const ConfigModuleSchema = z.union([GeneralConfigModuleSchema, WebhookConfigModuleSchema]);
46 |
47 | export const ConfigSchema = z
48 | .object({
49 | bot: BotKindSchema,
50 | modules: z.array(ConfigModuleSchema),
51 | })
52 | .strict();
53 |
--------------------------------------------------------------------------------
/packages/shared/src/api/bots/types.ts:
--------------------------------------------------------------------------------
1 | import type { z } from 'zod';
2 | import type { BOTS, ConfigSchema } from './schema.js';
3 |
4 | export type BotId = (typeof BOTS)[number];
5 | export type Config = z.infer;
6 |
--------------------------------------------------------------------------------
/packages/shared/src/api/index.ts:
--------------------------------------------------------------------------------
1 | export * from './auth/auth.js';
2 |
3 | export * from './bots/schema.js';
4 | export * from './bots/types.js';
5 |
--------------------------------------------------------------------------------
/packages/shared/src/index.ts:
--------------------------------------------------------------------------------
1 | export * from './api/index.js';
2 |
3 | export * from './util/computeAvatar.js';
4 | export * from './util/PermissionsBitField.js';
5 | export * from './util/promiseAllObject.js';
6 | export * from './util/setEquals.js';
7 | export * from './util/userToEmbedData.js';
8 |
--------------------------------------------------------------------------------
/packages/shared/src/util/PermissionsBitField.ts:
--------------------------------------------------------------------------------
1 | import type { ValueResolvable } from '@sapphire/bitfield';
2 | import { BitField } from '@sapphire/bitfield';
3 | import { PermissionFlagsBits } from 'discord-api-types/v10';
4 |
5 | export const PermissionsBitField = new BitField(PermissionFlagsBits);
6 |
7 | export type PermissionsResolvable = ValueResolvable;
8 |
--------------------------------------------------------------------------------
/packages/shared/src/util/README.md:
--------------------------------------------------------------------------------
1 | # util
2 |
3 | Contains general purpose utility functions and types with no dependencies or complexity attached.
4 |
--------------------------------------------------------------------------------
/packages/shared/src/util/computeAvatar.ts:
--------------------------------------------------------------------------------
1 | import type { APIUser, DefaultUserAvatarAssets, Snowflake } from 'discord-api-types/v10';
2 | import { CDNRoutes, ImageFormat, RouteBases, type UserAvatarFormat } from 'discord-api-types/v10';
3 |
4 | export function computeAvatarFormat(
5 | avatarHash: string,
6 | format: TFormat,
7 | ): ImageFormat.GIF | TFormat {
8 | return avatarHash.startsWith('a_') ? ImageFormat.GIF : format;
9 | }
10 |
11 | // New as in, Discord's new username system
12 | export function computeDefaultAvatarIndexNew(userId: Snowflake): DefaultUserAvatarAssets {
13 | const big = BigInt(userId);
14 | return Number((big >> 22n) % 6n) as DefaultUserAvatarAssets;
15 | }
16 |
17 | export function computeDefaultAvatarIndexOld(discriminator: string): DefaultUserAvatarAssets {
18 | return (Number.parseInt(discriminator, 10) % 5) as DefaultUserAvatarAssets;
19 | }
20 |
21 | export function computeDefaultAvatarIndex(user: APIUser | null, userId: Snowflake): DefaultUserAvatarAssets {
22 | if (!user) {
23 | return computeDefaultAvatarIndexNew(userId);
24 | }
25 |
26 | if (user.global_name) {
27 | return computeDefaultAvatarIndexNew(userId);
28 | }
29 |
30 | return computeDefaultAvatarIndexOld(user.discriminator);
31 | }
32 |
33 | export function computeAvatarUrl(user: APIUser | null, userId: Snowflake): string {
34 | // Use the default avatar
35 | if (!user?.avatar) {
36 | return `${RouteBases.cdn}${CDNRoutes.defaultUserAvatar(computeDefaultAvatarIndexNew(userId))}`;
37 | }
38 |
39 | const format = computeAvatarFormat(user.avatar, ImageFormat.PNG);
40 | return `${RouteBases.cdn}${CDNRoutes.userAvatar(userId, user.avatar, format)}`;
41 | }
42 |
--------------------------------------------------------------------------------
/packages/shared/src/util/promiseAllObject.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable tsdoc/syntax */
2 |
3 | /**
4 | * Transforms an object of promises into a promise of an object where all the values are awaited, much like
5 | * {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/all | Promise.all}.
6 | *
7 | * @remarks
8 | * This is the flow we follow:
9 | *
10 | * { a: Promise, b: Promise }
11 | *
12 | * => [ ['a', Promise], ['b', Promise] ]
13 | *
14 | * => [ Promise<['a', X]>, Promise<'b', Y>] ]
15 | *
16 | * => (via awaited Promise.all) [ ['a', X], ['b', Y] ]
17 | *
18 | * => Promise<{ a: X, b: Y }>
19 | */
20 | export async function promiseAllObject>>(
21 | obj: TRecord,
22 | ): Promise<{ [K in keyof TRecord]: Awaited }> {
23 | return Object.fromEntries(await Promise.all(Object.entries(obj).map(async ([key, value]) => [key, await value])));
24 | }
25 |
--------------------------------------------------------------------------------
/packages/shared/src/util/setEquals.ts:
--------------------------------------------------------------------------------
1 | export function setEquals(a: Set, b: Set): boolean {
2 | if (a.size !== b.size) {
3 | return false;
4 | }
5 |
6 | for (const aItem of a) {
7 | if (!b.has(aItem)) {
8 | return false;
9 | }
10 | }
11 |
12 | return true;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/shared/src/util/userToEmbedData.ts:
--------------------------------------------------------------------------------
1 | import type { APIEmbedAuthor, APIEmbedFooter, APIUser, Snowflake } from 'discord-api-types/v10';
2 | import { computeAvatarUrl } from './computeAvatar.js';
3 |
4 | export function userToEmbedAuthor(user: APIUser | null, userId: Snowflake): APIEmbedAuthor {
5 | return {
6 | name: `${user?.username ?? '[Unknown/Deleted user]'} (${userId})`,
7 | icon_url: computeAvatarUrl(user, userId),
8 | };
9 | }
10 |
11 | export function userToEmbedFooter(user: APIUser | null, userId: Snowflake): APIEmbedFooter {
12 | return {
13 | text: `${user?.username ?? '[Unknown/Deleted user]'} (${userId})`,
14 | icon_url: computeAvatarUrl(user, userId),
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/packages/shared/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist",
5 | "declaration": true,
6 | "declarationMap": true
7 | },
8 | "include": ["./src/**/*.ts"]
9 | }
10 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | generator kysely {
2 | provider = "prisma-kysely"
3 | output = "../packages/services/core/src"
4 | fileName = "db.ts"
5 | }
6 |
7 | datasource db {
8 | provider = "postgresql"
9 | url = env("DATABASE_URL")
10 | }
11 |
12 | model Experiment {
13 | name String @id
14 | createdAt DateTime @default(now())
15 | updatedAt DateTime?
16 | rangeStart Int
17 | rangeEnd Int
18 | overrides ExperimentOverride[]
19 | }
20 |
21 | model ExperimentOverride {
22 | id Int @id @default(autoincrement())
23 | guildId String
24 | experimentName String
25 | experiment Experiment @relation(fields: [experimentName], references: [name], onDelete: Cascade)
26 |
27 | @@unique([guildId, experimentName])
28 | }
29 |
30 | model Incident {
31 | id Int @id @default(autoincrement())
32 | stack String
33 | causeStack String?
34 | guildId String?
35 | createdAt DateTime @default(now())
36 | resolved Boolean @default(false)
37 | }
38 |
39 | enum ModCaseKind {
40 | Warn
41 | Timeout
42 | Kick
43 | Ban
44 | Untimeout
45 | Unban
46 | }
47 |
48 | model ModCase {
49 | id Int @id @default(autoincrement())
50 | guildId String
51 | kind ModCaseKind
52 | createdAt DateTime @default(now())
53 | reason String
54 | modId String
55 | targetId String
56 |
57 | referencedBy CaseReference[] @relation("caseReferencedBy")
58 | references CaseReference[] @relation("caseReferences")
59 | ModCaseLogMessage ModCaseLogMessage?
60 | }
61 |
62 | model CaseReference {
63 | referencedById Int
64 | referencedBy ModCase @relation("caseReferencedBy", fields: [referencedById], references: [id], onDelete: NoAction)
65 | referencesId Int
66 | references ModCase @relation("caseReferences", fields: [referencesId], references: [id], onDelete: Cascade)
67 |
68 | @@id([referencedById, referencesId])
69 | }
70 |
71 | model ModCaseLogMessage {
72 | caseId Int @id
73 | modCase ModCase @relation(fields: [caseId], references: [id], onDelete: Cascade)
74 | messageId String
75 | channelId String
76 | }
77 |
78 | enum LogWebhookKind {
79 | Mod
80 | }
81 |
82 | model LogWebhook {
83 | id Int @id @default(autoincrement())
84 | guildId String
85 | webhookId String
86 | webhookToken String
87 | threadId String?
88 | kind LogWebhookKind
89 | }
90 |
91 | model DiscordOAuth2User {
92 | id String @id
93 | accessToken String
94 | refreshToken String
95 | expiresAt DateTime
96 | }
97 |
--------------------------------------------------------------------------------
/services/api/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/api",
3 | "main": "./dist/index.js",
4 | "private": true,
5 | "version": "1.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "lint": "eslint src",
9 | "build": "tsc",
10 | "tag-docker": "docker image tag chatsift/chatsift-next:latest chatsift/chatsift-next:api"
11 | },
12 | "devDependencies": {
13 | "@types/cookie": "^1.0.0",
14 | "@types/jsonwebtoken": "^9.0.9",
15 | "@types/node": "^22.15.3",
16 | "kysely": "^0.27.6",
17 | "pino": "^9.6.0",
18 | "typescript": "^5.8.3"
19 | },
20 | "dependencies": {
21 | "@chatsift/parse-relative-time": "workspace:^",
22 | "@chatsift/readdir": "workspace:^",
23 | "@chatsift/service-core": "workspace:^",
24 | "@discordjs/core": "^2.1.0",
25 | "@discordjs/rest": "^2.5.0",
26 | "@fastify/cors": "^9.0.1",
27 | "@fastify/helmet": "^11.1.1",
28 | "@hapi/boom": "^10.0.1",
29 | "@sapphire/discord-utilities": "^3.4.4",
30 | "cookie": "^1.0.2",
31 | "fastify": "^4.29.1",
32 | "fastify-type-provider-zod": "^2.1.0",
33 | "inversify": "^6.2.2",
34 | "ioredis": "5.6.1",
35 | "jsonwebtoken": "^9.0.2",
36 | "reflect-metadata": "^0.2.2",
37 | "tslib": "^2.8.1",
38 | "undici": "^6.21.2",
39 | "zod": "^3.24.3"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/services/api/src/core-handlers/error.ts:
--------------------------------------------------------------------------------
1 | import { badRequest, Boom, isBoom } from '@hapi/boom';
2 | import { injectable } from 'inversify';
3 | import { ZodError } from 'zod';
4 | import type { FastifyServer, Registerable } from '../server.js';
5 |
6 | @injectable()
7 | export default class ErrorHandler implements Registerable {
8 | public register(server: FastifyServer) {
9 | // eslint-disable-next-line promise/prefer-await-to-callbacks
10 | server.setErrorHandler(async (error, request, reply) => {
11 | // Log appropriately depending on what was thrown
12 | if (reply.statusCode >= 400 && reply.statusCode < 500) {
13 | request.log.info(error);
14 | } else {
15 | request.log.error(error);
16 | }
17 |
18 | // Standardize errors
19 | let boom;
20 | if (isBoom(error)) {
21 | boom = error;
22 | } else if (error instanceof ZodError) {
23 | boom = badRequest('Invalid request payload', { details: error.errors });
24 | } else {
25 | boom = new Boom(error);
26 | }
27 |
28 | void reply.status(boom.output.statusCode);
29 |
30 | for (const [header, value] of Object.entries(boom.output.headers)) {
31 | void reply.header(header, value);
32 | }
33 |
34 | await reply.send({ ...boom.output.payload, ...boom.data });
35 | });
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/services/api/src/core-handlers/setup.ts:
--------------------------------------------------------------------------------
1 | import { Env } from '@chatsift/service-core';
2 | import cors from '@fastify/cors';
3 | import helmet from '@fastify/helmet';
4 | import { serializerCompiler, validatorCompiler } from 'fastify-type-provider-zod';
5 | import { injectable } from 'inversify';
6 | import type { FastifyServer, Registerable } from '../server.js';
7 |
8 | @injectable()
9 | export default class SetupHandler implements Registerable {
10 | public async register(server: FastifyServer) {
11 | await server.register(cors, { credentials: true, origin: Env.CORS ?? '*' });
12 | await server.register(helmet, {
13 | contentSecurityPolicy: Env.NODE_ENV === 'prod' ? undefined : false,
14 | referrerPolicy: false,
15 | });
16 |
17 | server.decorateRequest('discordUser', null);
18 |
19 | server.setValidatorCompiler(validatorCompiler);
20 | server.setSerializerCompiler(serializerCompiler);
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/services/api/src/handlers/bots.ts:
--------------------------------------------------------------------------------
1 | import { BotKindSchema, BOTS, ConfigSchema, type BotId, type Config } from '@chatsift/service-core';
2 | import type { ZodTypeProvider } from 'fastify-type-provider-zod';
3 | import { injectable } from 'inversify';
4 | import { z } from 'zod';
5 | import type { FastifyServer, Registerable } from '../server.js';
6 |
7 | @injectable()
8 | export default class BotsDataHandler implements Registerable {
9 | private readonly data: Record> = {
10 | automoderator: {
11 | modules: [
12 | {
13 | meta: {
14 | label: 'Logging',
15 | description: 'Set up logging for your server',
16 | },
17 | kind: 'webhook',
18 | options: [
19 | {
20 | meta: {
21 | label: 'Mod Logs',
22 | description: 'Log moderation actions',
23 | },
24 | },
25 | ],
26 | },
27 | ],
28 | },
29 | };
30 |
31 | public register(server: FastifyServer) {
32 | server
33 | .withTypeProvider()
34 | .route({
35 | method: 'GET',
36 | url: '/bots',
37 | schema: {
38 | response: {
39 | 200: z.array(BotKindSchema).readonly(),
40 | },
41 | },
42 | handler: async (_, reply) => {
43 | await reply.send(BOTS);
44 | },
45 | })
46 | .route({
47 | method: 'GET',
48 | url: '/bots/:bot',
49 | schema: {
50 | params: z
51 | .object({
52 | bot: BotKindSchema,
53 | })
54 | .strict(),
55 | response: {
56 | 200: ConfigSchema,
57 | },
58 | },
59 | handler: async (request, reply) => {
60 | const { bot } = request.params;
61 | const data = { ...this.data[bot], bot };
62 |
63 | await reply.send(data);
64 | },
65 | });
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/services/api/src/index.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import { dirname, join } from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 | import { readdirRecurse, ReadMode } from '@chatsift/readdir';
5 | import { DependencyManager, globalContainer, setupCrashLogs } from '@chatsift/service-core';
6 | import ErrorHandler from './core-handlers/error.js';
7 | import SetupHandler from './core-handlers/setup.js';
8 | import { Server, type Registerable, type RegisterableConstructor } from './server.js';
9 |
10 | const dependencyManager = globalContainer.get(DependencyManager);
11 | const logger = dependencyManager.registerLogger('api');
12 | dependencyManager.registerRedis();
13 | dependencyManager.registerApi(false);
14 |
15 | setupCrashLogs();
16 |
17 | const server = globalContainer.get(Server);
18 |
19 | export const handlersPath = join(dirname(fileURLToPath(import.meta.url)), 'handlers');
20 |
21 | // Those need to be loaded before everything else
22 | server.register(globalContainer.resolve(SetupHandler));
23 | server.register(globalContainer.resolve(ErrorHandler));
24 |
25 | for await (const path of readdirRecurse(handlersPath, { fileExtensions: ['js'], readMode: ReadMode.file })) {
26 | const moduleConstructor: RegisterableConstructor = await import(path).then((mod) => mod.default);
27 | const module = globalContainer.get(moduleConstructor);
28 |
29 | server.register(module);
30 | logger.info(`Loaded module ${module.constructor.name}`);
31 | }
32 |
33 | await server.listen();
34 |
--------------------------------------------------------------------------------
/services/api/src/server.ts:
--------------------------------------------------------------------------------
1 | import { Env, INJECTION_TOKENS } from '@chatsift/service-core';
2 | import Fastify from 'fastify';
3 | import { inject, injectable } from 'inversify';
4 | import type { Logger } from 'pino';
5 |
6 | export type FastifyServer = Server['fastify'];
7 |
8 | export interface Registerable {
9 | register(fastify: FastifyServer): void;
10 | }
11 |
12 | export type RegisterableConstructor = new (...args: any[]) => Registerable;
13 |
14 | @injectable()
15 | export class Server {
16 | public constructor(@inject(INJECTION_TOKENS.logger) private readonly logger: Logger) {}
17 |
18 | private readonly fastify = Fastify({ logger: this.logger });
19 |
20 | public register(registerable: Registerable): void {
21 | registerable.register(this.fastify);
22 | }
23 |
24 | public async listen(): Promise {
25 | const port = Number(Env.API_PORT);
26 |
27 | await this.fastify.ready();
28 | await this.fastify.listen({ port, host: '0.0.0.0' });
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/services/api/src/struct/StateCookie.ts:
--------------------------------------------------------------------------------
1 | import { Buffer } from 'node:buffer';
2 | import { randomBytes } from 'node:crypto';
3 |
4 | export class StateCookie {
5 | public static from(data: string): StateCookie {
6 | const bytes = Buffer.from(data, 'base64');
7 | const nonce = bytes.subarray(0, 16);
8 | const createdAt = new Date(bytes.readUInt32LE(16));
9 | const redirectURI = bytes.subarray(20).toString();
10 |
11 | return new this(redirectURI, nonce, createdAt);
12 | }
13 |
14 | public constructor(redirectURI: string);
15 | public constructor(redirectURI: string, nonce: Buffer, createdAt: Date);
16 |
17 | public constructor(
18 | public readonly redirectURI: string,
19 | private readonly nonce: Buffer = randomBytes(16),
20 | private readonly createdAt: Date = new Date(),
21 | ) {}
22 |
23 | public toBytes(): Buffer {
24 | const time = Buffer.allocUnsafe(4);
25 | time.writeUInt32LE(Math.floor(this.createdAt.getTime() / 1_000));
26 | return Buffer.concat([this.nonce, time, Buffer.from(this.redirectURI)]);
27 | }
28 |
29 | public toCookie(): string {
30 | return this.toBytes().toString('base64');
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/services/api/src/util/discordAuth.ts:
--------------------------------------------------------------------------------
1 | import { globalContainer } from '@chatsift/service-core';
2 | import type { Boom } from '@hapi/boom';
3 | import { forbidden, unauthorized } from '@hapi/boom';
4 | import { parse as parseCookie } from 'cookie';
5 | import type { FastifyReply, FastifyRequest } from 'fastify';
6 | import jwt from 'jsonwebtoken';
7 | import { Auth, type APIUserWithGuilds } from '../struct/Auth.js';
8 |
9 | declare module 'fastify' {
10 | interface FastifyRequest {
11 | discordUser?: APIUserWithGuilds;
12 | }
13 | }
14 |
15 | /**
16 | * Validate the JWT and see if the user's Discord token is still valid
17 | *
18 | * @param fallthrough - Whether to carry on if the user is not authenticated
19 | */
20 | export function discordAuth(fallthrough: boolean) {
21 | const auth = globalContainer.get(Auth);
22 | const fail = (boom: Boom) => {
23 | if (!fallthrough) {
24 | throw boom;
25 | }
26 | };
27 |
28 | return async (request: FastifyRequest, reply: FastifyReply) => {
29 | const cookies = parseCookie(request.headers.cookie ?? '');
30 |
31 | const accessToken = cookies.access_token;
32 | const refreshToken = cookies.refresh_token;
33 |
34 | if (!accessToken) {
35 | fail(unauthorized('missing authorization', 'Bearer'));
36 | return;
37 | }
38 |
39 | try {
40 | const user = await auth.verifyToken(accessToken);
41 |
42 | try {
43 | const discordUser = await auth.fetchDiscordUser(user.accessToken);
44 |
45 | // Permission checks
46 | const match = /guilds\/(?\d{17,19})/.exec(request.originalUrl);
47 | if (match?.groups?.guildId) {
48 | const guild = discordUser.guilds.find((guild) => guild.id === match?.groups?.guildId);
49 | if (!guild) {
50 | throw forbidden('cannot perform actions on this guild');
51 | }
52 | }
53 |
54 | request.discordUser = discordUser;
55 | } catch (error) {
56 | request.log.error(error, 'discord auth failed');
57 | }
58 | } catch (error) {
59 | if (error instanceof jwt.TokenExpiredError) {
60 | if (!refreshToken) {
61 | fail(unauthorized('expired access token and missing refresh token', 'Bearer'));
62 | return;
63 | }
64 |
65 | const newTokens = auth.refreshTokens(accessToken, refreshToken);
66 | auth.appendAuthCookies(reply, newTokens);
67 |
68 | const user = await auth.verifyToken(newTokens.access.token);
69 | const discordUser = await auth.fetchDiscordUser(user.accessToken);
70 | // eslint-disable-next-line require-atomic-updates
71 | request.discordUser = discordUser;
72 | } else if (error instanceof jwt.JsonWebTokenError) {
73 | fail(unauthorized('malformed token', 'Bearer'));
74 | } else {
75 | throw error;
76 | }
77 | }
78 |
79 | if (!request.discordUser) {
80 | fail(unauthorized('could not auth with discord'));
81 | }
82 | };
83 | }
84 |
--------------------------------------------------------------------------------
/services/api/src/util/replyHelpers.ts:
--------------------------------------------------------------------------------
1 | import { serialize, type SerializeOptions } from 'cookie';
2 | import type { FastifyReply } from 'fastify';
3 |
4 | export function appendToHeader(reply: FastifyReply, header: string, value: string[] | number | string): void {
5 | const prev = reply.getHeader(header);
6 |
7 | let final = value;
8 | if (prev) {
9 | final = Array.isArray(prev) ? prev.concat(value as string) : ([prev].concat(value) as string[]);
10 | }
11 |
12 | void reply.header(header, final);
13 | }
14 |
15 | export function appendCookie(reply: FastifyReply, name: string, data: string, options?: SerializeOptions): void {
16 | const value = serialize(name, data, options);
17 | // Fastify already behaves like appendToHeader for Set-Cookie
18 | void reply.header('Set-Cookie', value);
19 | }
20 |
--------------------------------------------------------------------------------
/services/api/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/services/api/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist"
5 | },
6 | "include": ["./src/**/*.ts"]
7 | }
8 |
--------------------------------------------------------------------------------
/services/automoderator/discord-proxy/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/automoderator-discord-proxy",
3 | "main": "./dist/index.js",
4 | "private": true,
5 | "version": "1.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "lint": "eslint src",
9 | "build": "tsc",
10 | "tag-docker": "docker image tag chatsift/chatsift-next:latest chatsift/chatsift-next:automoderator-discord-proxy"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^22.15.3",
14 | "pino": "^9.6.0",
15 | "typescript": "^5.8.3"
16 | },
17 | "dependencies": {
18 | "@automoderator/core": "workspace:^",
19 | "@discordjs/core": "^2.1.0",
20 | "@discordjs/proxy": "^2.1.1",
21 | "@discordjs/rest": "^2.5.0",
22 | "inversify": "^6.2.2",
23 | "ioredis": "5.6.1",
24 | "reflect-metadata": "^0.2.2",
25 | "tslib": "^2.8.1"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/services/automoderator/discord-proxy/src/cache.ts:
--------------------------------------------------------------------------------
1 | import type { ICacheEntity } from '@automoderator/core';
2 | import { globalContainer, CacheFactory, GuildCacheEntity } from '@automoderator/core';
3 | import { injectable } from 'inversify';
4 |
5 | @injectable()
6 | export class ProxyCache {
7 | public constructor(private readonly cacheFactory: CacheFactory) {}
8 |
9 | private readonly cacheEntityTokensMap: Record ICacheEntity> = {
10 | '/guilds/:id': GuildCacheEntity,
11 | };
12 |
13 | private readonly idResolversMap: Record string> = {
14 | '/guilds/:id': (parameters) => parameters[0]!,
15 | };
16 |
17 | public async fetch(route: string): Promise {
18 | const [normalized, parameters] = this.normalizeRoute(route);
19 |
20 | const cacheEntityToken = this.cacheEntityTokensMap[normalized];
21 | const idResolver = this.idResolversMap[normalized];
22 |
23 | if (cacheEntityToken && idResolver) {
24 | const cacheEntity = globalContainer.get>(cacheEntityToken);
25 | const cache = this.cacheFactory.build(cacheEntity);
26 |
27 | return cache.get(idResolver(parameters));
28 | }
29 |
30 | return null;
31 | }
32 |
33 | public async update(route: string, data: unknown): Promise {
34 | const [normalized, parameters] = this.normalizeRoute(route);
35 |
36 | const cacheEntityToken = this.cacheEntityTokensMap[normalized];
37 | const idResolver = this.idResolversMap[normalized];
38 |
39 | if (cacheEntityToken && idResolver) {
40 | const cacheEntity = globalContainer.get>(cacheEntityToken);
41 | const cache = this.cacheFactory.build(cacheEntity);
42 |
43 | await cache.set(idResolver(parameters), data);
44 | }
45 | }
46 |
47 | private normalizeRoute(route: string): [normalized: string, parameters: string[]] {
48 | const normalized = route.replaceAll(/\d{17,19}/g, ':id');
49 | const parameters = route
50 | .slice(1)
51 | .split('/')
52 | .filter((component) => /\d{17,19}/.test(component));
53 |
54 | return [normalized, parameters];
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/services/automoderator/discord-proxy/src/clone.ts:
--------------------------------------------------------------------------------
1 | // MIT License
2 |
3 | // Copyright (c) 2024 Levan Basharuli
4 |
5 | // Permission is hereby granted, free of charge, to any person obtaining a copy
6 | // of this software and associated documentation files (the "Software"), to deal
7 | // in the Software without restriction, including without limitation the rights
8 | // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | // copies of the Software, and to permit persons to whom the Software is
10 | // furnished to do so, subject to the following conditions:
11 |
12 | // The above copyright notice and this permission notice shall be included in all
13 | // copies or substantial portions of the Software.
14 |
15 | // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | // SOFTWARE.
22 |
23 | // See: https://github.com/levansuper/readable-stream-clone/tree/master
24 |
25 | import { Readable } from 'node:stream';
26 | import type { ReadableOptions } from 'node:stream';
27 |
28 | export class ReadableStreamClone extends Readable {
29 | public constructor(readableStream: Readable, options?: ReadableOptions) {
30 | super(options);
31 |
32 | readableStream.on('data', (chunk) => {
33 | this.push(chunk);
34 | });
35 |
36 | readableStream.on('end', () => {
37 | this.push(null);
38 | });
39 |
40 | readableStream.on('error', (err) => {
41 | this.emit('error', err);
42 | });
43 | }
44 |
45 | public override _read() {}
46 | }
47 |
--------------------------------------------------------------------------------
/services/automoderator/discord-proxy/src/index.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import { credentialsForCurrentBot, DependencyManager, globalContainer, setupCrashLogs } from '@automoderator/core';
3 | import { ProxyServer } from './server.js';
4 |
5 | const dependencyManager = globalContainer.get(DependencyManager);
6 | const logger = dependencyManager.registerLogger('discordproxy');
7 | dependencyManager.registerRedis();
8 |
9 | setupCrashLogs();
10 |
11 | const server = globalContainer.get(ProxyServer);
12 |
13 | const credentials = credentialsForCurrentBot();
14 | const port = Number(new URL(credentials.proxyURL).port);
15 |
16 | server.listen(port);
17 | logger.info(`Listening on port ${port}`);
18 |
--------------------------------------------------------------------------------
/services/automoderator/discord-proxy/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/services/automoderator/discord-proxy/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist"
5 | },
6 | "include": ["./src/**/*.ts"]
7 | }
8 |
--------------------------------------------------------------------------------
/services/automoderator/gateway/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/automoderator-gateway",
3 | "main": "./dist/index.js",
4 | "private": true,
5 | "version": "1.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "lint": "eslint src",
9 | "build": "tsc",
10 | "tag-docker": "docker image tag chatsift/chatsift-next:latest chatsift/chatsift-next:automoderator-gateway"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^22.15.3",
14 | "pino": "^9.6.0",
15 | "typescript": "^5.8.3"
16 | },
17 | "dependencies": {
18 | "@automoderator/core": "workspace:^",
19 | "@discordjs/brokers": "^1.0.0",
20 | "@discordjs/core": "^2.1.0",
21 | "@discordjs/rest": "^2.5.0",
22 | "@discordjs/ws": "^2.0.2",
23 | "inversify": "^6.2.2",
24 | "ioredis": "5.6.1",
25 | "reflect-metadata": "^0.2.2",
26 | "tslib": "^2.8.1",
27 | "undici": "^6.21.2"
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/services/automoderator/gateway/src/index.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import { createServer } from 'node:http';
3 | import { globalContainer, DependencyManager, setupCrashLogs, credentialsForCurrentBot } from '@automoderator/core';
4 | import { Gateway } from './gateway.js';
5 |
6 | const dependencyManager = globalContainer.get(DependencyManager);
7 | dependencyManager.registerLogger('gateway');
8 | dependencyManager.registerRedis();
9 | dependencyManager.registerApi();
10 |
11 | setupCrashLogs();
12 |
13 | const gateway = globalContainer.get(Gateway);
14 | await gateway.connect();
15 |
16 | const server = createServer(async (req, res) => {
17 | if (req.url === '/guilds') {
18 | res.statusCode = 200;
19 | res.setHeader('Content-Type', 'application/json');
20 |
21 | return res.end(JSON.stringify({ guilds: [...gateway.guildsIds] }));
22 | }
23 |
24 | res.statusCode = 404;
25 | res.end();
26 | });
27 |
28 | const credentials = credentialsForCurrentBot();
29 | server.listen(new URL(credentials.gatewayURL).port);
30 |
--------------------------------------------------------------------------------
/services/automoderator/gateway/src/server.ts:
--------------------------------------------------------------------------------
1 | // Simple server to let other parts of the stack know what guilds this bot has
2 |
3 | import { createServer } from 'node:http';
4 |
5 | export const server = createServer(async (req, res) => {
6 | if (req.url === '/guilds') {
7 | res.statusCode = 200;
8 | res.setHeader('Content-Type', 'application/json');
9 |
10 | res.end(JSON.stringify({ guilds: ['guild1', 'guild2'] }));
11 | }
12 | });
13 |
--------------------------------------------------------------------------------
/services/automoderator/gateway/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/services/automoderator/gateway/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist"
5 | },
6 | "include": ["./src/**/*.ts"]
7 | }
8 |
--------------------------------------------------------------------------------
/services/automoderator/interactions/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/automoderator-interactions",
3 | "main": "./dist/index.js",
4 | "private": true,
5 | "version": "1.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "lint": "eslint src",
9 | "build": "tsc",
10 | "tag-docker": "docker image tag chatsift/chatsift-next:latest chatsift/chatsift-next:automoderator-interactions"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^22.15.3",
14 | "kysely": "^0.27.6",
15 | "pino": "^9.6.0",
16 | "typescript": "^5.8.3"
17 | },
18 | "dependencies": {
19 | "@automoderator/core": "workspace:^",
20 | "@chatsift/discord-utils": "workspace:^",
21 | "@chatsift/parse-relative-time": "workspace:^",
22 | "@chatsift/readdir": "workspace:^",
23 | "@discordjs/brokers": "^1.0.0",
24 | "@discordjs/core": "^2.1.0",
25 | "@discordjs/formatters": "^0.6.1",
26 | "@discordjs/rest": "^2.5.0",
27 | "@discordjs/ws": "^2.0.2",
28 | "@sapphire/discord-utilities": "^3.4.4",
29 | "@sapphire/snowflake": "^3.5.5",
30 | "bin-rw": "^0.1.1",
31 | "coral-command": "^0.10.1",
32 | "inversify": "^6.2.2",
33 | "ioredis": "5.6.1",
34 | "nanoid": "^5.1.5",
35 | "reflect-metadata": "^0.2.2",
36 | "tslib": "^2.8.1",
37 | "undici": "^6.21.2"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/services/automoderator/interactions/src/helpers/verifyValidCaseReferences.ts:
--------------------------------------------------------------------------------
1 | import type { CaseWithLogMessage, IDatabase } from '@automoderator/core';
2 | import { ApplicationCommandOptionType, type APIApplicationCommandOption } from '@discordjs/core';
3 | import type { InteractionOptionResolver } from '@sapphire/discord-utilities';
4 | import { ActionKind, HandlerStep, type InteractionHandler as CoralInteractionHandler } from 'coral-command';
5 |
6 | export const REFERENCES_OPTION = {
7 | name: 'references',
8 | description: 'References to other case IDs (comma seperated)',
9 | type: ApplicationCommandOptionType.String,
10 | required: false,
11 | } as const satisfies APIApplicationCommandOption;
12 |
13 | export async function* verifyValidCaseReferences(
14 | options: InteractionOptionResolver,
15 | database: IDatabase,
16 | ): CoralInteractionHandler {
17 | const references =
18 | options
19 | .getString('references')
20 | ?.split(',')
21 | .map((ref) => ref.trim()) ?? null;
22 |
23 | if (!references) {
24 | return [];
25 | }
26 |
27 | const numbers = references.map(Number);
28 | const invalidNumIndex = numbers.findIndex((num) => Number.isNaN(num));
29 |
30 | if (invalidNumIndex !== -1) {
31 | yield* HandlerStep.from(
32 | {
33 | action: ActionKind.Reply,
34 | options: {
35 | content: `Reference case ID "${references[invalidNumIndex]}" is not a valid number.`,
36 | },
37 | },
38 | true,
39 | );
40 | }
41 |
42 | const cases = await database.getModCaseBulk(numbers);
43 |
44 | if (cases.length !== references.length) {
45 | const set = new Set(cases.map((cs) => cs.id));
46 |
47 | const invalid = numbers.filter((num) => !set.has(num));
48 |
49 | yield* HandlerStep.from(
50 | {
51 | action: ActionKind.Reply,
52 | options: {
53 | content: `Reference ID(s) ${invalid.join(', ')} do not exist.`,
54 | },
55 | },
56 | true,
57 | );
58 | }
59 |
60 | return cases;
61 | }
62 |
--------------------------------------------------------------------------------
/services/automoderator/interactions/src/index.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import { dirname, join } from 'node:path';
3 | import { fileURLToPath } from 'node:url';
4 | import {
5 | CoralCommandHandler,
6 | globalContainer,
7 | DependencyManager,
8 | setupCrashLogs,
9 | encode,
10 | decode,
11 | ICommandHandler,
12 | type DiscordGatewayEventsMap,
13 | USEFUL_HANDLERS_PATH,
14 | type HandlerModuleConstructor,
15 | type HandlerModule,
16 | Env,
17 | credentialsForCurrentBot,
18 | } from '@automoderator/core';
19 | import { readdirRecurseManyAsync, ReadMode } from '@chatsift/readdir';
20 | import { PubSubRedisBroker } from '@discordjs/brokers';
21 | import { GatewayDispatchEvents } from '@discordjs/core';
22 | import type { InteractionHandler as CoralInteractionHandler } from 'coral-command';
23 | import { IComponentStateStore } from './state/IComponentStateStore.js';
24 | import { RedisComponentStateStore } from './state/RedisComponentDataStore.js';
25 |
26 | const dependencyManager = globalContainer.get(DependencyManager);
27 | const logger = dependencyManager.registerLogger('interactions');
28 | const redis = dependencyManager.registerRedis();
29 | const api = dependencyManager.registerApi();
30 |
31 | globalContainer.bind(IComponentStateStore).to(RedisComponentStateStore);
32 | globalContainer.bind>(ICommandHandler).to(CoralCommandHandler);
33 |
34 | setupCrashLogs();
35 |
36 | const commandHandler = globalContainer.get>(ICommandHandler);
37 |
38 | export const serviceHandlersPath = join(dirname(fileURLToPath(import.meta.url)), 'handlers');
39 |
40 | for (const path of await readdirRecurseManyAsync([serviceHandlersPath, USEFUL_HANDLERS_PATH], {
41 | fileExtensions: ['js'],
42 | readMode: ReadMode.file,
43 | })) {
44 | const moduleConstructor: HandlerModuleConstructor = await import(path).then(
45 | (mod) => mod.default,
46 | );
47 | const module = globalContainer.get>(moduleConstructor);
48 |
49 | module.register(commandHandler);
50 | logger.info(`Loaded handler/module ${module.constructor.name}`);
51 | }
52 |
53 | const broker = new PubSubRedisBroker(redis, {
54 | encode,
55 | decode,
56 | // TODO: Make those constants
57 | group: 'interactions',
58 | });
59 |
60 | async function ensureFirstDeployment(): Promise {
61 | const credentials = credentialsForCurrentBot();
62 | const existing = await api.applicationCommands.getGlobalCommands(credentials.clientId);
63 | if (!existing.length) {
64 | logger.info('No global commands found, deploying (one-time)...');
65 | await commandHandler.deployCommands();
66 | }
67 | }
68 |
69 | await ensureFirstDeployment();
70 |
71 | broker.on(GatewayDispatchEvents.InteractionCreate, async ({ data: interaction, ack }) => {
72 | await commandHandler.handle(interaction);
73 | await ack();
74 | });
75 |
76 | await broker.subscribe([GatewayDispatchEvents.InteractionCreate]);
77 |
--------------------------------------------------------------------------------
/services/automoderator/interactions/src/state/IComponentStateStore.ts:
--------------------------------------------------------------------------------
1 | import type { ModCaseKind } from '@automoderator/core';
2 | import { injectable } from 'inversify';
3 |
4 | export interface ConfirmModCaseState {
5 | deleteMessageSeconds: number | null;
6 | kind: ModCaseKind;
7 | reason: string;
8 | references: number[];
9 | targetId: string;
10 | timeoutDuration: number | null;
11 | }
12 |
13 | @injectable()
14 | /**
15 | * Responsible for mapping nanoids to state for cross-process/cross-instance state around message component interactions
16 | */
17 | export abstract class IComponentStateStore {
18 | public constructor() {
19 | if (this.constructor === IComponentStateStore) {
20 | throw new Error('This class cannot be instantiated.');
21 | }
22 | }
23 |
24 | public abstract getPendingModCase(id: string): Promise;
25 | public abstract setPendingModCase(id: string, state: ConfirmModCaseState): Promise;
26 | }
27 |
--------------------------------------------------------------------------------
/services/automoderator/interactions/src/state/RedisComponentDataStore.ts:
--------------------------------------------------------------------------------
1 | import { INJECTION_TOKENS } from '@automoderator/core';
2 | import { createRecipe, DataType, type Recipe } from 'bin-rw';
3 | import { inject, injectable } from 'inversify';
4 | import type { Redis } from 'ioredis';
5 | import { IComponentStateStore, type ConfirmModCaseState } from './IComponentStateStore.js';
6 |
7 | @injectable()
8 | /**
9 | * Responsible for mapping nanoids to state for cross-process/cross-instance state around message component interactions
10 | */
11 | export class RedisComponentStateStore extends IComponentStateStore {
12 | // It's incredibly hard to add narrower types to bin-rw in its current state.
13 | // Instead, because as casts suck a lot here and they allow us to forget adding a prop to this recipe,
14 | // we use the type below, which at least guarantees that all keys are present.
15 | private readonly pendingModCaseRecipe: Recipe> = createRecipe({
16 | kind: DataType.String,
17 | reason: DataType.String,
18 | targetId: DataType.String,
19 | references: [DataType.U32],
20 | deleteMessageSeconds: DataType.U32,
21 | timeoutDuration: DataType.U32,
22 | });
23 |
24 | public constructor(@inject(INJECTION_TOKENS.redis) private readonly redis: Redis) {
25 | super();
26 | }
27 |
28 | public override async getPendingModCase(id: string): Promise {
29 | const data = await this.redis.getBuffer(`component-state:mod-case:${id}`);
30 |
31 | if (!data) {
32 | return null;
33 | }
34 |
35 | return this.pendingModCaseRecipe.decode(data);
36 | }
37 |
38 | public override async setPendingModCase(id: string, state: ConfirmModCaseState): Promise {
39 | await this.redis.set(`component-state:mod-case:${id}`, this.pendingModCaseRecipe.encode(state), 'EX', 180);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/services/automoderator/interactions/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/services/automoderator/interactions/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist"
5 | },
6 | "include": ["./src/**/*.ts"]
7 | }
8 |
--------------------------------------------------------------------------------
/services/automoderator/observer/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@chatsift/automoderator-observer",
3 | "main": "./dist/index.js",
4 | "private": true,
5 | "version": "1.0.0",
6 | "type": "module",
7 | "scripts": {
8 | "lint": "eslint src",
9 | "build": "tsc",
10 | "tag-docker": "docker image tag chatsift/chatsift-next:latest chatsift/chatsift-next:automoderator-observer"
11 | },
12 | "devDependencies": {
13 | "@types/node": "^22.15.3",
14 | "pino": "^9.6.0",
15 | "typescript": "^5.8.3"
16 | },
17 | "dependencies": {
18 | "@automoderator/core": "workspace:^",
19 | "@discordjs/brokers": "^1.0.0",
20 | "@discordjs/core": "^2.1.0",
21 | "@discordjs/formatters": "^0.6.1",
22 | "@discordjs/rest": "^2.5.0",
23 | "@discordjs/ws": "^2.0.2",
24 | "inversify": "^6.2.2",
25 | "ioredis": "5.6.1",
26 | "reflect-metadata": "^0.2.2",
27 | "tslib": "^2.8.1",
28 | "undici": "^6.21.2"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/services/automoderator/observer/src/index.ts:
--------------------------------------------------------------------------------
1 | import 'reflect-metadata';
2 | import {
3 | globalContainer,
4 | DependencyManager,
5 | setupCrashLogs,
6 | encode,
7 | decode,
8 | type DiscordGatewayEventsMap,
9 | INotifier,
10 | IDatabase,
11 | ModCaseKind,
12 | } from '@automoderator/core';
13 | import { PubSubRedisBroker } from '@discordjs/brokers';
14 | import {
15 | AuditLogEvent,
16 | GatewayDispatchEvents,
17 | type GatewayGuildAuditLogEntryCreateDispatchData,
18 | } from '@discordjs/core';
19 | import { time } from '@discordjs/formatters';
20 |
21 | const dependencyManager = globalContainer.get(DependencyManager);
22 | dependencyManager.registerLogger('observer');
23 | const redis = dependencyManager.registerRedis();
24 | const api = dependencyManager.registerApi();
25 |
26 | setupCrashLogs();
27 |
28 | const notifier = globalContainer.get(INotifier);
29 | const database = globalContainer.get(IDatabase);
30 |
31 | const broker = new PubSubRedisBroker(redis, {
32 | group: 'observer',
33 | encode,
34 | decode,
35 | });
36 |
37 | async function handlePotentialTimeout(data: GatewayGuildAuditLogEntryCreateDispatchData): Promise {
38 | const mod = await api.users.get(data.user_id!);
39 | const target = await api.users.get(data.target_id!);
40 |
41 | if (mod.bot || target.bot) {
42 | return;
43 | }
44 |
45 | const change = data.changes?.find((change) => change.key === 'communication_disabled_until');
46 | if (change?.new_value) {
47 | const modCase = await database.createModCase({
48 | guildId: data.guild_id,
49 | targetId: data.target_id!,
50 | modId: data.user_id!,
51 | reason: `${data.reason ?? 'No reason provided.'} | Until ${time(new Date(change.new_value))}`,
52 | kind: ModCaseKind.Timeout,
53 | references: [],
54 | });
55 |
56 | await notifier.tryNotifyTargetModCase(modCase);
57 | await notifier.logModCase({ modCase, mod, target, references: [] });
58 | } else if (change?.old_value) {
59 | const modCase = await database.createModCase({
60 | guildId: data.guild_id,
61 | targetId: data.target_id!,
62 | modId: data.user_id!,
63 | reason: data.reason ?? 'No reason provided.',
64 | kind: ModCaseKind.Untimeout,
65 | references: [],
66 | });
67 |
68 | await notifier.tryNotifyTargetModCase(modCase);
69 | await notifier.logModCase({ modCase, mod, target, references: [] });
70 | }
71 | }
72 |
73 | broker.on(GatewayDispatchEvents.GuildAuditLogEntryCreate, async ({ data, ack }) => {
74 | if (data.action_type === AuditLogEvent.MemberUpdate) {
75 | await handlePotentialTimeout(data);
76 | }
77 |
78 | await ack();
79 | });
80 |
81 | await broker.subscribe([GatewayDispatchEvents.GuildMemberUpdate, GatewayDispatchEvents.GuildAuditLogEntryCreate]);
82 |
--------------------------------------------------------------------------------
/services/automoderator/observer/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/services/automoderator/observer/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "../../../tsconfig.json",
3 | "compilerOptions": {
4 | "outDir": "./dist"
5 | },
6 | "include": ["./src/**/*.ts"]
7 | }
8 |
--------------------------------------------------------------------------------
/tsconfig.eslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "./tsconfig.json",
3 | "compilerOptions": {
4 | "allowJs": true
5 | },
6 | "include": [
7 | "**/*.ts",
8 | "**/*.tsx",
9 | "**/*.js",
10 | "**/*.cjs",
11 | "**/*.mjs",
12 | "**/*.jsx",
13 | "**/*.test.ts",
14 | "**/*.test.js",
15 | "**/*.spec.ts",
16 | "**/*.spec.js"
17 | ],
18 | "exclude": []
19 | }
20 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "strict": true,
4 | "alwaysStrict": true,
5 | "moduleResolution": "node",
6 | "removeComments": true,
7 | "pretty": true,
8 | "module": "ESNext",
9 | "target": "ES2020",
10 | "lib": ["ESNext"],
11 | "sourceMap": true,
12 | "incremental": true,
13 | "skipLibCheck": true,
14 | "noEmitHelpers": true,
15 | "importHelpers": true,
16 | "esModuleInterop": true,
17 | "noUncheckedIndexedAccess": true,
18 | "emitDecoratorMetadata": true,
19 | "experimentalDecorators": true,
20 | "noImplicitOverride": true,
21 | "verbatimModuleSyntax": true
22 | },
23 | "exclude": ["./**/__mocks__/**/*.ts", "./**/__tests__/**/*.ts"]
24 | }
25 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { relative, resolve } from 'node:path';
2 | import process from 'node:process';
3 | import { defineConfig, type Options } from 'tsup';
4 |
5 | type ConfigOptions = Pick<
6 | Options,
7 | 'entry' | 'esbuildOptions' | 'format' | 'globalName' | 'minify' | 'noExternal' | 'sourcemap' | 'target'
8 | >;
9 |
10 | export const createTsupConfig = ({
11 | globalName,
12 | format = ['esm', 'cjs'],
13 | target = 'es2021',
14 | sourcemap = true,
15 | minify = false,
16 | entry = ['src/index.ts'],
17 | noExternal,
18 | esbuildOptions,
19 | }: ConfigOptions = {}) =>
20 | defineConfig({
21 | clean: true,
22 | entry,
23 | format,
24 | minify,
25 | skipNodeModulesBundle: true,
26 | sourcemap,
27 | target,
28 | tsconfig: relative(__dirname, resolve(process.cwd(), 'tsconfig.json')),
29 | keepNames: true,
30 | globalName,
31 | noExternal,
32 | esbuildOptions,
33 | });
34 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turborepo.org/schema.json",
3 | "tasks": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": ["dist/**"],
7 | "inputs": ["tsconfig.json", "package.json", "src/**/*.ts", "tsup.config.ts", "schema.prisma"]
8 | },
9 | "lint": {
10 | "dependsOn": ["^build"],
11 | "outputs": [],
12 | "inputs": ["tsconfig.json", "package.json", "src/**/*.ts"]
13 | },
14 | "test": {
15 | "dependsOn": ["^build"],
16 | "outputs": [],
17 | "inputs": ["tsconfig.json", "package.json", "src/**/*.ts", "vitest.config.ts"]
18 | },
19 | "tag-docker": {
20 | "outputs": [],
21 | "inputs": ["src/**/*.ts", "Dockerfile"]
22 | }
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | exclude: ['**/node_modules', '**/dist', '.idea', '.git', '.cache'],
6 | passWithNoTests: true,
7 | typecheck: {
8 | enabled: true,
9 | include: ['**/__tests__/types.test.ts'],
10 | tsconfig: 'tsconfig.json',
11 | },
12 | coverage: {
13 | enabled: true,
14 | reporter: ['text', 'lcov', 'clover'],
15 | exclude: ['**/dist', '**/__tests__', '**/__mocks__', '**/tsup.config.ts', '**/vitest.config.ts'],
16 | },
17 | },
18 | });
19 |
--------------------------------------------------------------------------------