├── .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 |
9 | 15 | Join our Discord server 16 | 17 |
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 |
32 | {icon ? ( 33 | 34 | Guild icon 41 | 42 | ) : ( 43 | 44 | {getGuildAcronym(data.name)} 45 | 46 | )} 47 |
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 |
33 | © ChatSift, 2022 - Present 34 |
35 | 43 |
44 |

Theme:

45 | 46 | {isMounted ? : } 47 |
48 |
49 |
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 |
75 | 76 |

{user?.username}

77 | 82 |
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 | 4 | 10 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgChatSift.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgChatSift() { 2 | return ( 3 | 4 | 11 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgClose.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgClose() { 2 | return ( 3 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgDarkTheme.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgDarkTheme() { 2 | return ( 3 | 4 | 8 | 12 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgDiscord.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgDiscord() { 2 | return ( 3 | 4 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgGitHub.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgGitHub() { 2 | return ( 3 | 4 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgHamburger.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgHamburger() { 2 | return ( 3 | 4 | 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgLightTheme.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgLightTheme() { 2 | return ( 3 | 4 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /apps/website/src/components/svg/SvgRefresh.tsx: -------------------------------------------------------------------------------- 1 | export default function SvgRefresh() { 2 | return ( 3 | 4 | 5 | 9 | 10 | 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 | [![GitHub](https://img.shields.io/badge/License-GNU%20AGPLv3-yellow.svg)](https://github.com/chatsift/chatsift/blob/main/LICENSE) 4 | [![npm](https://img.shields.io/npm/v/@chatsift/discord-utils?color=crimson&logo=npm)](https://www.npmjs.com/package/@chatsift/discord-utils) 5 | [![TypeScript](https://github.com/chatsift/chatsift/actions/workflows/test.yml/badge.svg)](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 | [![GitHub](https://img.shields.io/badge/License-GNU%20AGPLv3-yellow.svg)](https://github.com/chatsift/chatsift/blob/main/LICENSE) 4 | [![npm](https://img.shields.io/npm/v/@chatsift/parse-relative-time?color=crimson&logo=npm)](https://www.npmjs.com/package/@chatsift/parse-relative-time) 5 | [![TypeScript](https://github.com/chatsift/chatsift/actions/workflows/test.yml/badge.svg)](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 | [![GitHub](https://img.shields.io/badge/License-GNU%20AGPLv3-yellow.svg)](https://github.com/chatsift/chatsift/blob/main/LICENSE) 4 | [![npm](https://img.shields.io/npm/v/@chatsift/pino-rotate-file?color=crimson&logo=npm)](https://www.npmjs.com/package/@chatsift/pino-rotate-file) 5 | [![TypeScript](https://github.com/chatsift/chatsift/actions/workflows/test.yml/badge.svg)](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 | [![GitHub](https://img.shields.io/badge/License-GNU%20AGPLv3-yellow.svg)](https://github.com/chatsift/chatsift/blob/main/LICENSE) 4 | [![npm](https://img.shields.io/npm/v/@chatsift/readdir?color=crimson&logo=npm)](https://www.npmjs.com/package/@chatsift/readdir) 5 | [![TypeScript](https://github.com/chatsift/chatsift/actions/workflows/test.yml/badge.svg)](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 | --------------------------------------------------------------------------------