├── .gitattributes ├── Procfile ├── .prettierignore ├── .vscode └── settings.json ├── packages ├── bot │ ├── src │ │ ├── util │ │ │ ├── tokens.ts │ │ │ ├── InferArrayT.ts │ │ │ ├── diff.ts │ │ │ ├── getSortedMemberRoles.ts │ │ │ ├── ellipsis.ts │ │ │ ├── i18nInit.ts │ │ │ ├── memoize.ts │ │ │ ├── getUserGuilds.ts │ │ │ ├── templateString.ts │ │ │ ├── durationAutoComplete.ts │ │ │ ├── logger.ts │ │ │ ├── closeThread.ts │ │ │ ├── sendMemberThreadMessage.ts │ │ │ ├── handleStaffThreadMessage.ts │ │ │ ├── sendStaffThreadMessage.ts │ │ │ └── handleThreadManagement.ts │ │ ├── .DS_Store │ │ ├── jobs │ │ │ ├── autoUnblock.ts │ │ │ ├── preventAutoArchive.ts │ │ │ └── autoCloseThreads.ts │ │ ├── deploy.ts │ │ ├── events │ │ │ ├── error.ts │ │ │ ├── ready.ts │ │ │ ├── interactionCreate.ts │ │ │ └── modmail │ │ │ │ ├── modmailMessageDelete.ts │ │ │ │ ├── modmailMessageUpdate.ts │ │ │ │ └── modmailMessageCreate.ts │ │ ├── struct │ │ │ ├── Event.ts │ │ │ ├── Component.ts │ │ │ ├── Env.ts │ │ │ ├── EventHandler.ts │ │ │ ├── Command.ts │ │ │ ├── JobManager.ts │ │ │ ├── SelectMenuPaginator.ts │ │ │ └── CommandHandler.ts │ │ ├── commands │ │ │ ├── snippets │ │ │ │ ├── add.ts │ │ │ │ ├── index.ts │ │ │ │ ├── remove.ts │ │ │ │ ├── edit.ts │ │ │ │ ├── list.ts │ │ │ │ └── show.ts │ │ │ ├── context-menus │ │ │ │ ├── reply-anon.ts │ │ │ │ ├── open.ts │ │ │ │ ├── createSnippet.ts │ │ │ │ ├── expose.ts │ │ │ │ └── reply.ts │ │ │ ├── open.ts │ │ │ ├── edit.ts │ │ │ ├── reply.ts │ │ │ ├── unblock.ts │ │ │ ├── delete.ts │ │ │ ├── alert.ts │ │ │ ├── block.ts │ │ │ ├── logs.ts │ │ │ ├── config.ts │ │ │ └── close.ts │ │ ├── index.ts │ │ └── modals │ │ │ └── snippets │ │ │ └── add.ts │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ ├── package.json │ └── locales │ │ └── en-US │ │ └── translation.json └── api │ ├── not_importable.js │ ├── src │ ├── util │ │ ├── snowflakeSchema.ts │ │ ├── env.ts │ │ ├── logger.ts │ │ └── models.ts │ ├── routes │ │ ├── index.ts │ │ ├── getSnippets.ts │ │ ├── getSettings.ts │ │ ├── getSnippet.ts │ │ ├── updateSettings.ts │ │ ├── deleteSnippet.ts │ │ ├── updateSnippet.ts │ │ └── createSnippet.ts │ ├── routeTypes.ts │ └── index.ts │ ├── tsconfig.json │ ├── tsconfig.eslint.json │ └── package.json ├── .husky ├── commit-msg └── pre-commit ├── .github ├── auto_assign.yml ├── workflows │ ├── pr-automation.yml │ ├── sync-labels.yml │ ├── test.yml │ └── deploy.yml └── labels.yml ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20220705121005_fix_thread_message_constraint │ │ └── migration.sql │ └── 20220703194746_init │ │ └── migration.sql └── schema.prisma ├── .dockerignore ├── .prettierrc.json ├── .gitignore ├── .commitlintrc.json ├── turbo.json ├── tsconfig.eslint.json ├── .yarnrc.yml ├── .env.example ├── tsconfig.json ├── .eslintrc.json ├── LICENSE ├── Dockerfile ├── docker-compose.yml ├── package.json └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | worker: yarn build && yarn start-bot -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | coverage/* 3 | **/dist/* 4 | .yarn/* 5 | .turbo 6 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /packages/bot/src/util/tokens.ts: -------------------------------------------------------------------------------- 1 | export const tokens = { logger: Symbol('logger') } as const; 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn build && yarn lint 5 | -------------------------------------------------------------------------------- /packages/bot/src/util/InferArrayT.ts: -------------------------------------------------------------------------------- 1 | export type InferArrayT = Ts extends (infer T)[] ? T : never; 2 | -------------------------------------------------------------------------------- /packages/bot/src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/OpenAI-Discord/ModMail/HEAD/packages/bot/src/.DS_Store -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | addReviewers: true 2 | reviewers: 3 | - didinele 4 | numberOfReviewers: 0 5 | runOnDraft: true 6 | -------------------------------------------------------------------------------- /packages/api/not_importable.js: -------------------------------------------------------------------------------- 1 | throw new Error('This module should only be used for compile-time imports. It should not be imported in runtime.'); 2 | -------------------------------------------------------------------------------- /packages/api/src/util/snowflakeSchema.ts: -------------------------------------------------------------------------------- 1 | import { s } from '@sapphire/shapeshift'; 2 | 3 | export const snowflakeSchema = s.string.regex(/\d{17,19}/); 4 | -------------------------------------------------------------------------------- /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" -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | .env 3 | .env.*.example 4 | 5 | coverage 6 | **/dist/* 7 | **/Dockerfile 8 | docker-compose.yml 9 | 10 | .pnp.* 11 | 12 | .turbo 13 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "useTabs": true, 4 | "singleQuote": true, 5 | "quoteProps": "as-needed", 6 | "trailingComma": "all", 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | 4 | coverage/* 5 | **/dist/* 6 | 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/sdks 12 | !.yarn/versions 13 | .pnp.* 14 | logs 15 | 16 | .turbo 17 | -------------------------------------------------------------------------------- /packages/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "declaration": true 6 | }, 7 | "include": ["./src/**/*.ts"], 8 | "exclude": ["./**/__tests__"] 9 | } 10 | -------------------------------------------------------------------------------- /packages/bot/src/jobs/autoUnblock.ts: -------------------------------------------------------------------------------- 1 | import { parentPort } from 'node:worker_threads'; 2 | import { PrismaClient } from '@prisma/client'; 3 | 4 | const prisma = new PrismaClient(); 5 | await prisma.block.deleteMany({ where: { expiresAt: { lt: new Date() } } }); 6 | 7 | parentPort?.postMessage('done'); 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "baseBranch": "origin/main", 4 | "pipeline": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "outputs": ["dist/**"] 8 | }, 9 | "lint": { 10 | "dependsOn": ["^build"], 11 | "outputs": [] 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/pr-automation.yml: -------------------------------------------------------------------------------- 1 | name: 'PR Automation' 2 | on: 3 | pull_request_target: 4 | jobs: 5 | triage: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Automatically assign reviewers 9 | if: github.event.action == 'opened' 10 | uses: kentaro-m/auto-assign-action@v1.2.1 11 | -------------------------------------------------------------------------------- /packages/bot/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "./dist", 5 | "paths": { 6 | "#struct/*": ["./src/struct/*.ts"], 7 | "#util/*": ["./src/util/*.ts"] 8 | } 9 | }, 10 | "include": ["./src/**/*.ts"], 11 | "exclude": ["./**/__tests__"] 12 | } 13 | -------------------------------------------------------------------------------- /packages/bot/src/deploy.ts: -------------------------------------------------------------------------------- 1 | import { container } from 'tsyringe'; 2 | import { CommandHandler } from '#struct/CommandHandler'; 3 | 4 | export async function deploySlashCommands(): Promise { 5 | const commandHandler = container.resolve(CommandHandler); 6 | await commandHandler.init(); 7 | await commandHandler.registerInteractions(); 8 | } 9 | -------------------------------------------------------------------------------- /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 | "**/*.jsx", 12 | "**/*.test.ts", 13 | "**/*.test.js", 14 | "**/*.spec.ts", 15 | "**/*.spec.js" 16 | ], 17 | "exclude": [] 18 | } 19 | -------------------------------------------------------------------------------- /packages/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 | "**/*.jsx", 12 | "**/*.test.ts", 13 | "**/*.test.js", 14 | "**/*.spec.ts", 15 | "**/*.spec.js" 16 | ], 17 | "exclude": [] 18 | } 19 | -------------------------------------------------------------------------------- /packages/bot/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 | "**/*.jsx", 12 | "**/*.test.ts", 13 | "**/*.test.js", 14 | "**/*.spec.ts", 15 | "**/*.spec.js" 16 | ], 17 | "exclude": [] 18 | } 19 | -------------------------------------------------------------------------------- /packages/bot/src/util/diff.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-param-reassign */ 2 | export function diff(oldContent: string, newContent: string) { 3 | oldContent = oldContent 4 | .split('\n') 5 | .map((line) => `- ${line}`) 6 | .join('\n'); 7 | 8 | newContent = newContent 9 | .split('\n') 10 | .map((line) => `+ ${line}`) 11 | .join('\n'); 12 | 13 | return `${oldContent}\n\n${newContent}`; 14 | } 15 | -------------------------------------------------------------------------------- /packages/bot/src/events/error.ts: -------------------------------------------------------------------------------- 1 | import { Events } from 'discord.js'; 2 | import { singleton } from 'tsyringe'; 3 | import type { Event } from '#struct/Event'; 4 | import { logger } from '#util/logger'; 5 | 6 | @singleton() 7 | export default class implements Event { 8 | public readonly name = Events.Error; 9 | 10 | public handle(error: Error) { 11 | logger.error(error); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 5 | spec: '@yarnpkg/plugin-workspace-tools' 6 | - path: .yarn/plugins/@yarnpkg/plugin-version.cjs 7 | spec: '@yarnpkg/plugin-version' 8 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 9 | spec: '@yarnpkg/plugin-interactive-tools' 10 | 11 | yarnPath: .yarn/releases/yarn-3.2.1.cjs 12 | -------------------------------------------------------------------------------- /packages/bot/src/util/getSortedMemberRoles.ts: -------------------------------------------------------------------------------- 1 | import type { GuildMember } from 'discord.js'; 2 | 3 | export function getSortedMemberRolesString(member: GuildMember): string { 4 | if (member.roles.cache.size <= 1) { 5 | return 'none'; 6 | } 7 | 8 | return member.roles.cache 9 | .filter((role) => role.id !== member.guild.id) 10 | .sort((a, b) => b.position - a.position) 11 | .map((role) => role.toString()) 12 | .join(', '); 13 | } 14 | -------------------------------------------------------------------------------- /packages/api/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | export { default as GetSettingsRoute } from './getSettings'; 2 | export { default as UpdateSettingsRoute } from './updateSettings'; 3 | export { default as GetSnippetsRoute } from './getSnippets'; 4 | export { default as GetSnippetRoute } from './getSnippet'; 5 | export { default as CreateSnippetRoute } from './createSnippet'; 6 | export { default as UpdateSnippetRoute } from './updateSnippet'; 7 | export { default as DeleteSnippetRoute } from './deleteSnippet'; 8 | -------------------------------------------------------------------------------- /packages/bot/src/util/ellipsis.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cuts off text after the given length - appending "..." at the end 3 | * 4 | * @param text - The text to cut off 5 | * @param total - The maximum length of the text 6 | */ 7 | export function ellipsis(text: string, total: number): string { 8 | if (text.length <= total) { 9 | return text; 10 | } 11 | 12 | const keep = total - 3; 13 | if (keep < 1) { 14 | return text.slice(0, total); 15 | } 16 | 17 | return `${text.slice(0, keep)}...`; 18 | } 19 | -------------------------------------------------------------------------------- /packages/bot/src/util/i18nInit.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from 'node:url'; 2 | import i18next from 'i18next'; 3 | import FsBackend from 'i18next-fs-backend'; 4 | 5 | export async function i18nInit() { 6 | return i18next.use(FsBackend).init({ 7 | backend: { loadPath: fileURLToPath(new URL('../../locales/{{lng}}/{{ns}}.json', import.meta.url)) }, 8 | cleanCode: true, 9 | fallbackLng: ['en-US'], 10 | defaultNS: 'translation', 11 | lng: 'en-US', 12 | ns: ['translation'], 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /prisma/migrations/20220705121005_fix_thread_message_constraint/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[threadId,localThreadMessageId]` on the table `ThreadMessage` will be added. If there are existing duplicate values, this will fail. 5 | 6 | */ 7 | -- DropIndex 8 | DROP INDEX "ThreadMessage_guildId_localThreadMessageId_key"; 9 | 10 | -- CreateIndex 11 | CREATE UNIQUE INDEX "ThreadMessage_threadId_localThreadMessageId_key" ON "ThreadMessage"("threadId", "localThreadMessageId"); 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_PORT=5432 # You should only change this if you need 2 instances on the same machine 2 | DATABASE_URL="postgresql://modmail:admin@localhost:${DATABASE_PORT}/modmail" # You should almost never change this, unless you know what you're doing 3 | NODE_ENV=prod # Change to dev if you're trying to work on the project locally 4 | DISCORD_CLIENT_ID= # Copy this from the dev portal 5 | DISCORD_TOKEN= # Copy this from the dev portal 6 | DEBUG_JOBS= # Set to true to enable job debugging, keep in mind this is very spammy and should only be used for debugging -------------------------------------------------------------------------------- /.github/workflows/sync-labels.yml: -------------------------------------------------------------------------------- 1 | name: Sync Labels 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | workflow_dispatch: 6 | push: 7 | branches: 8 | - main 9 | paths: 10 | - '.github/labels.yml' 11 | jobs: 12 | synclabels: 13 | name: Sync Labels 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | - name: Sync labels 20 | uses: crazy-max/ghaction-github-labeler@v3 21 | with: 22 | github-token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /packages/bot/src/struct/Event.ts: -------------------------------------------------------------------------------- 1 | import { basename, extname } from 'node:path'; 2 | import type { ClientEvents } from 'discord.js'; 3 | 4 | export type Event = { 5 | handle(...args: ClientEvents[Name]): unknown; 6 | readonly name?: Name; 7 | }; 8 | 9 | export type EventConstructor = new (...args: any[]) => Event; 10 | 11 | export type EventInfo = { 12 | name: string; 13 | }; 14 | 15 | export function getEventInfo(path: string): EventInfo | null { 16 | if (extname(path) !== '.js') { 17 | return null; 18 | } 19 | 20 | return { name: basename(path, '.js') }; 21 | } 22 | -------------------------------------------------------------------------------- /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 | "importsNotUsedAsValues": "error" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["neon/common", "neon/node", "neon/typescript", "neon/prettier", "neon/module"], 4 | "parserOptions": { 5 | "project": "./tsconfig.eslint.json" 6 | }, 7 | "env": { 8 | "jest": true 9 | }, 10 | "globals": { 11 | "NodeJS": "readonly" 12 | }, 13 | "rules": { 14 | "@typescript-eslint/ban-types": "off", 15 | "@typescript-eslint/no-extraneous-class": "off", 16 | "curly": ["error", "all"], 17 | "no-eq-null": "off", 18 | "no-unused-vars": "off", 19 | "import/extensions": "off", 20 | "no-useless-constructor": "off", 21 | "unicorn/require-post-message-target-origin": "off" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/bot/src/util/memoize.ts: -------------------------------------------------------------------------------- 1 | import { setTimeout } from 'node:timers'; 2 | 3 | type MemoizableFunction = (arg: any) => unknown; 4 | 5 | const CACHE = new WeakMap>(); 6 | 7 | export function memoize(fn: T, ttl: number): T { 8 | return ((arg) => { 9 | let memoized = CACHE.get(fn); 10 | if (!memoized) { 11 | memoized = new Map(); 12 | CACHE.set(fn, memoized); 13 | } 14 | 15 | if (memoized.has(arg)) { 16 | return memoized.get(arg)!; 17 | } 18 | 19 | const res = fn(arg); 20 | memoized.set(arg, res); 21 | setTimeout(() => memoized!.delete(arg), ttl).unref(); 22 | 23 | return res; 24 | }) as T; 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (C) 2022 didinele 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 | -------------------------------------------------------------------------------- /packages/bot/src/struct/Component.ts: -------------------------------------------------------------------------------- 1 | import { basename, extname } from 'node:path'; 2 | import type { Awaitable, MessageComponentInteraction } from 'discord.js'; 3 | 4 | export type ComponentInfo = { 5 | name: string; 6 | }; 7 | 8 | export type Component = MessageComponentInteraction<'cached'>> = { 9 | handle(interaction: Type, ...args: any[]): Awaitable; 10 | readonly name?: string; 11 | }; 12 | 13 | export type ComponentConstructor = new (...args: any[]) => Component; 14 | 15 | export function getComponentInfo(path: string): ComponentInfo | null { 16 | if (extname(path) !== '.js') { 17 | return null; 18 | } 19 | 20 | return { name: basename(path, '.js') }; 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.16 2 | LABEL name "modmail" 3 | 4 | WORKDIR /usr/modmail 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 openssl1.1-compat 9 | 10 | COPY turbo.json package.json tsconfig.json yarn.lock .yarnrc.yml ./ 11 | COPY .yarn ./.yarn 12 | 13 | COPY packages/api/package.json ./packages/api/package.json 14 | COPY packages/bot/package.json ./packages/bot/package.json 15 | 16 | RUN yarn --immutable 17 | 18 | COPY prisma ./prisma 19 | RUN yarn prisma generate 20 | 21 | COPY packages/api ./packages/api 22 | COPY packages/bot ./packages/bot 23 | 24 | RUN yarn turbo run build 25 | 26 | RUN yarn workspaces focus --all --production 27 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Quality Check 2 | on: [push, pull_request] 3 | jobs: 4 | quality: 5 | name: Quality Check 6 | runs-on: ubuntu-latest 7 | env: 8 | TURBO_TEAM: ${{ secrets.TURBO_TEAM }} 9 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 10 | steps: 11 | - name: Checkout repository 12 | uses: actions/checkout@v2 13 | 14 | - name: Install Node.js v16 15 | uses: actions/setup-node@v2 16 | with: 17 | node-version: 16 18 | cache: 'yarn' 19 | cache-dependency-path: yarn.lock 20 | 21 | - name: Install dependencies 22 | run: yarn --immutable 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 | -------------------------------------------------------------------------------- /packages/bot/src/struct/Env.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { singleton } from 'tsyringe'; 3 | 4 | @singleton() 5 | export class Env { 6 | public readonly discordToken = process.env.DISCORD_TOKEN!; 7 | 8 | public readonly discordClientId = process.env.DISCORD_CLIENT_ID!; 9 | 10 | public readonly logChannelId: string = process.env.LOG_CHANNEL_ID!; 11 | 12 | public readonly isProd = process.env.NODE_ENV === 'prod'; 13 | 14 | public readonly deploySlashCommands = Boolean(process.env.DEPLOY); 15 | 16 | public readonly debugJobs = process.env.DEBUG_JOBS === 'true'; 17 | 18 | private readonly KEYS = ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID', 'NODE_ENV'] as const; 19 | 20 | public constructor() { 21 | for (const key of this.KEYS) { 22 | if (!(key in process.env)) { 23 | throw new Error(`Missing required environment variable: ${key}`); 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/api/src/util/env.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { singleton } from 'tsyringe'; 3 | 4 | @singleton() 5 | export class Env { 6 | public readonly port: number = Number.parseInt(process.env.PORT ?? '8080', 10); 7 | 8 | public readonly discordToken: string = process.env.DISCORD_TOKEN!; 9 | 10 | public readonly discordClientId: string = process.env.DISCORD_CLIENT_ID!; 11 | 12 | public readonly logChannelId: string = process.env.LOG_CHANNEL_ID!; 13 | 14 | public readonly isProd = process.env.NODE_ENV === 'prod'; 15 | 16 | public readonly cors = process.env.CORS?.split(',') ?? []; 17 | 18 | private readonly KEYS: string[] = ['DISCORD_TOKEN', 'DISCORD_CLIENT_ID']; 19 | 20 | public constructor() { 21 | for (const key of this.KEYS) { 22 | if (!(key in process.env)) { 23 | throw new Error(`Missing required environment variable: ${key}`); 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/bot/src/commands/snippets/add.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | APIApplicationCommandSubcommandOption, 3 | ChatInputCommandInteraction, 4 | PermissionResolvable, 5 | } from 'discord.js'; 6 | import { singleton } from 'tsyringe'; 7 | import promptSnippetAdd from '../../modals/snippets/add'; 8 | import { getLocalizedProp, type Subcommand } from '#struct/Command'; 9 | 10 | @singleton() 11 | export default class implements Subcommand { 12 | public readonly interactionOptions: Omit = { 13 | ...getLocalizedProp('name', 'commands.snippets.add.name'), 14 | ...getLocalizedProp('description', 'commands.snippets.add.description'), 15 | options: [], 16 | }; 17 | 18 | public requiredClientPermissions: PermissionResolvable = 'SendMessages'; 19 | 20 | public async handle(interaction: ChatInputCommandInteraction<'cached'>) { 21 | return promptSnippetAdd(interaction); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /packages/bot/src/events/ready.ts: -------------------------------------------------------------------------------- 1 | import { ActivityType, Client } from 'discord.js'; 2 | import { Events } from 'discord.js'; 3 | import { singleton } from 'tsyringe'; 4 | import type { Event } from '#struct/Event'; 5 | import { JobManager } from '#struct/JobManager'; 6 | import { logger } from '#util/logger'; 7 | 8 | @singleton() 9 | export default class implements Event { 10 | public readonly name = Events.ClientReady; 11 | 12 | public constructor(private readonly jobManager: JobManager) {} 13 | 14 | public async handle(client: Client) { 15 | logger.info(`Ready as ${client.user.tag} (${client.user.id})`); 16 | client.user.setPresence({ 17 | activities: [ 18 | { name: `for your DMs`, type: ActivityType.Watching } 19 | ], 20 | status: 'online' 21 | }); 22 | await this.jobManager.register(); 23 | await this.jobManager.start(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.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 | workflow_dispatch: 12 | 13 | jobs: 14 | deploy: 15 | name: Deploy 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - name: Checkout repository 20 | uses: actions/checkout@v2 21 | 22 | - name: Install Node v16 23 | uses: actions/setup-node@v1 24 | with: 25 | node-version: 16 26 | 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v1 29 | 30 | - name: Login to DockerHub 31 | run: docker login -u ${{ secrets.DOCKERHUB_USERNAME }} -p ${{ secrets.DOCKERHUB_TOKEN }} 32 | 33 | - name: Build the images 34 | run: docker build -t chatsift/modmail:latest -f ./Dockerfile . 35 | 36 | - name: Push to DockerHub 37 | run: docker push --all-tags chatsift/modmail 38 | -------------------------------------------------------------------------------- /packages/bot/src/util/getUserGuilds.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import type { Guild } from 'discord.js'; 3 | import { Client, Collection } from 'discord.js'; 4 | import { container } from 'tsyringe'; 5 | 6 | export async function getUserGuilds(userId: string): Promise> { 7 | const client = container.resolve(Client); 8 | const prisma = container.resolve(PrismaClient); 9 | 10 | const results = await Promise.all( 11 | Array.from(client.guilds.cache.values(), async (guild) => 12 | guild.members 13 | .fetch(userId) 14 | .then(async () => { 15 | const settings = await prisma.guildSettings.findFirst({ where: { guildId: guild.id } }); 16 | if (settings?.modmailChannelId) { 17 | return [guild.id, guild]; 18 | } 19 | 20 | return null; 21 | }) 22 | .catch(() => null), 23 | ), 24 | ); 25 | 26 | return new Collection(results.filter((result): result is [string, Guild] => result !== null)); 27 | } 28 | -------------------------------------------------------------------------------- /packages/bot/src/util/templateString.ts: -------------------------------------------------------------------------------- 1 | import { time } from '@discordjs/builders'; 2 | import type { GuildMember } from 'discord.js'; 3 | import { TimestampStyles } from 'discord.js'; 4 | import { getSortedMemberRolesString } from '#util/getSortedMemberRoles'; 5 | 6 | export type TemplateData = { 7 | guildName: string; 8 | joinDate: string; 9 | roles: string; 10 | userId: string; 11 | username: string; 12 | }; 13 | 14 | export function templateDataFromMember(member: GuildMember): TemplateData { 15 | return { 16 | username: member.user.username, 17 | userId: member.user.id, 18 | joinDate: time(member.joinedAt!, TimestampStyles.LongDate), 19 | roles: getSortedMemberRolesString(member), 20 | guildName: member.guild.name, 21 | }; 22 | } 23 | 24 | export function templateString(content: string, data: TemplateData) { 25 | return content.replace(/{{ (?