Compare commits

..

No commits in common. "master" and "master" have entirely different histories.

66 changed files with 3921 additions and 8522 deletions

View File

@ -1,330 +1,223 @@
{
"plugins": [
// "jest",
"@typescript-eslint"
],
"env": {
"es6": true,
"node": true
// "jest/globals": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly",
"BigInt": true
},
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaVersion": 2022,
"sourceType": "module"
},
"rules": {
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/no-non-null-assertion": "off",
"accessor-pairs": "warn",
"array-callback-return": "warn",
"array-bracket-newline": [
"warn",
"consistent"
],
"array-bracket-spacing": [
"warn",
"always",
{
"objectsInArrays": false,
"arraysInArrays": false
}
],
"arrow-spacing": "warn",
"block-scoped-var": "warn",
"block-spacing": [
"warn",
"always"
],
"brace-style": [
"warn",
"allman"
],
"callback-return": "warn",
"camelcase": "warn",
"comma-dangle": [
"warn",
"only-multiline"
],
"comma-spacing": [
"warn",
{
"after": true,
"before": false
}
],
"comma-style": "warn",
"computed-property-spacing": [
"warn",
"never"
],
"consistent-this": "warn",
"dot-notation": [
"warn",
{
"allowKeywords": true
}
],
"dot-location": [
"error",
"property"
],
"eol-last": [
"warn",
"never"
],
"eqeqeq": "error",
"func-call-spacing": "warn",
"func-name-matching": "warn",
"func-names": "warn",
"func-style": "warn",
"function-paren-newline": "warn",
"generator-star-spacing": "warn",
"grouped-accessor-pairs": "warn",
"guard-for-in": "warn",
"handle-callback-err": "warn",
"id-blacklist": "warn",
"id-match": "warn",
"implicit-arrow-linebreak": "warn",
"indent": [
"warn",
4,
{
"SwitchCase": 1
}
],
"init-declarations": "warn",
"quotes": [
"error",
"single"
],
"jsx-quotes": [
"warn",
"prefer-single"
],
"key-spacing": [
"warn",
{
"beforeColon": false,
"afterColon": true
}
],
"keyword-spacing": [
"warn",
{
"after": true,
"before": true
}
],
"linebreak-style": [
"error",
"unix"
],
"lines-around-comment": "warn",
"lines-around-directive": "warn",
"max-classes-per-file": "warn",
"max-nested-callbacks": "warn",
"max-len": [
"warn",
{
"code": 140,
"ignoreComments": true,
"ignoreStrings": true,
"ignoreTemplateLiterals": true,
"ignoreRegExpLiterals": true
}
],
"max-lines-per-function": [
"warn",
140
],
"max-depth": [
"warn",
3
],
"new-parens": "warn",
"no-alert": "warn",
"no-array-constructor": "warn",
// "no-bitwise": "warn",
"no-buffer-constructor": "warn",
"no-caller": "warn",
"no-console": "warn",
"no-constant-binary-expression": "error",
"no-div-regex": "warn",
"no-dupe-else-if": "warn",
"no-duplicate-imports": "warn",
"no-else-return": "warn",
"no-empty-function": "warn",
"no-eq-null": "error",
"no-eval": "warn",
"no-extend-native": "warn",
"no-extra-bind": "warn",
"no-extra-label": "warn",
"no-floating-decimal": "warn",
"no-implicit-coercion": "warn",
"no-implicit-globals": "warn",
"no-implied-eval": "error",
"no-import-assign": "warn",
"no-invalid-this": "warn",
"no-iterator": "warn",
"no-label-var": "warn",
"no-lone-blocks": "warn",
"no-lonely-if": "warn",
"no-loop-func": "warn",
"no-mixed-requires": "warn",
"no-multi-assign": "warn",
"no-multi-spaces": "warn",
"no-multi-str": "warn",
"no-multiple-empty-lines": "warn",
"no-native-reassign": "warn",
"no-negated-in-lhs": "warn",
"no-negated-condition": "error",
"no-nested-ternary": "warn",
"no-new": "warn",
"no-new-func": "warn",
"no-new-object": "warn",
"no-new-require": "warn",
"no-new-wrappers": "warn",
"no-octal-escape": "warn",
"no-path-concat": "warn",
"no-process-exit": "warn",
"no-proto": "warn",
"no-restricted-globals": "warn",
"no-restricted-imports": "warn",
"no-restricted-modules": "warn",
"no-restricted-properties": "warn",
"no-restricted-syntax": "warn",
"no-return-assign": [
"warn",
"except-parens"
],
"no-return-await": "warn",
"no-script-url": "warn",
"no-self-compare": "warn",
"no-sequences": "warn",
"no-setter-return": "warn",
"no-spaced-func": "warn",
"@typescript-eslint/no-shadow": "error",
"no-tabs": "warn",
"no-template-curly-in-string": "error",
"no-throw-literal": "warn",
"no-trailing-spaces": "warn",
"no-undef-init": "error",
"no-undefined": "error",
"no-unmodified-loop-condition": "warn",
"no-unneeded-ternary": "error",
"no-unused-expressions": "warn",
"@typescript-eslint/no-use-before-define": "error",
"no-useless-call": "warn",
"no-useless-computed-key": "warn",
"no-useless-concat": "warn",
"no-useless-constructor": "warn",
"no-useless-rename": "warn",
"no-useless-return": "warn",
"no-var": "warn",
// "no-void": "warn",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": [
"warn",
"below"
],
"object-curly-spacing": [
"warn",
"always"
],
"object-property-newline": [
"warn",
{
"allowAllPropertiesOnSameLine": true
}
],
"object-shorthand": "warn",
"one-var-declaration-per-line": "warn",
"operator-assignment": "warn",
"operator-linebreak": [
"warn",
"before"
],
"padding-line-between-statements": "warn",
"padded-blocks": [
"warn",
{
"switches": "never"
},
{
"allowSingleLineBlocks": true
}
],
"prefer-arrow-callback": "warn",
"prefer-const": "warn",
"prefer-destructuring": "warn",
"prefer-exponentiation-operator": "warn",
"prefer-numeric-literals": "warn",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "warn",
"prefer-regex-literals": "warn",
"prefer-rest-params": "warn",
"prefer-spread": "warn",
"require-jsdoc": "warn",
"require-unicode-regexp": "warn",
"rest-spread-spacing": "warn",
"semi": "error",
"semi-spacing": "warn",
"semi-style": [
"warn",
"last"
],
"space-before-blocks": "warn",
"space-before-function-paren": [
"error",
"always"
],
"space-in-parens": [
"warn",
"never"
],
"spaced-comment": [
"warn",
"always"
],
"strict": "warn",
"switch-colon-spacing": "warn",
"symbol-description": "warn",
"template-curly-spacing": [
"warn",
"never"
],
"template-tag-spacing": "warn",
"unicode-bom": [
"warn",
"never"
],
"vars-on-top": "warn",
"wrap-iife": "warn",
"wrap-regex": "error",
"yield-star-spacing": "warn",
"yoda": [
"warn",
"never"
],
"no-warning-comments": [
1,
{
"terms": [
"todo",
"fixme"
],
"location": "anywhere"
}
]
}
}
module.exports = {
"env": {
"es6": true,
"node": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 2020,
"sourceType": "module"
},
"rules": {
"accessor-pairs": "warn",
"array-callback-return": "warn",
"array-bracket-newline": [ "warn", "consistent" ],
"array-bracket-spacing": [ "warn", "always", { "objectsInArrays": false, "arraysInArrays": false }],
// "arrow-parens": "warn",
"arrow-spacing": "warn",
"block-scoped-var": "warn",
"block-spacing": [ "warn", "always" ],
"brace-style": [ "warn", "1tbs" ],
"callback-return": "warn",
"camelcase": "warn",
"comma-dangle": [ "warn", "only-multiline" ],
"comma-spacing": [
"warn",
{
"after": true,
"before": false
}
],
"comma-style": "warn",
"computed-property-spacing": [
"warn",
"never"
],
"consistent-this": "warn",
"dot-notation": [
"warn",
{
"allowKeywords": true
}
],
"dot-location": [
"error",
"property"
],
"eol-last": [
"warn",
"never"
],
"eqeqeq": "warn",
"func-call-spacing": "warn",
"func-name-matching": "warn",
"func-names": "warn",
"func-style": "warn",
"function-paren-newline": "warn",
"generator-star-spacing": "warn",
"grouped-accessor-pairs": "warn",
"guard-for-in": "warn",
"handle-callback-err": "warn",
"id-blacklist": "warn",
"id-match": "warn",
"implicit-arrow-linebreak": "warn",
"indent": "warn",
"init-declarations": "warn",
"jsx-quotes": [ "warn", "prefer-single" ],
"key-spacing": [ "warn", { "beforeColon": false, "afterColon": true }],
"keyword-spacing": [ "warn", { "after": true, "before": true }],
"linebreak-style": [
"error",
"windows"
],
"lines-around-comment": "warn",
"lines-around-directive": "warn",
"lines-between-class-members": [
"warn",
"always"
],
"max-classes-per-file": "warn",
"max-nested-callbacks": "warn",
"new-parens": "warn",
"no-alert": "warn",
"no-array-constructor": "warn",
"no-bitwise": "warn",
"no-buffer-constructor": "warn",
"no-caller": "warn",
"no-console": "warn",
"no-div-regex": "warn",
"no-dupe-else-if": "warn",
"no-duplicate-imports": "warn",
"no-else-return": "warn",
"no-empty-function": "warn",
"no-eq-null": "warn",
"no-eval": "warn",
"no-extend-native": "warn",
"no-extra-bind": "warn",
"no-extra-label": "warn",
"no-extra-parens": "warn",
"no-floating-decimal": "warn",
"no-implicit-coercion": "warn",
"no-implicit-globals": "warn",
"no-implied-eval": "error",
"no-import-assign": "warn",
"no-invalid-this": "warn",
"no-iterator": "warn",
"no-label-var": "warn",
// "no-labels": "warn",
"no-lone-blocks": "warn",
"no-lonely-if": "warn",
"no-loop-func": "warn",
"no-mixed-requires": "warn",
"no-multi-assign": "warn",
"no-multi-spaces": "warn",
"no-multi-str": "warn",
"no-multiple-empty-lines": "warn",
"no-native-reassign": "warn",
"no-negated-in-lhs": "warn",
"no-negated-condition": "error",
"no-nested-ternary": "warn",
"no-new": "warn",
"no-new-func": "warn",
"no-new-object": "warn",
"no-new-require": "warn",
"no-new-wrappers": "warn",
"no-octal-escape": "warn",
"no-path-concat": "warn",
"no-process-exit": "warn",
"no-proto": "warn",
"no-restricted-globals": "warn",
"no-restricted-imports": "warn",
"no-restricted-modules": "warn",
"no-restricted-properties": "warn",
"no-restricted-syntax": "warn",
"no-return-assign": "warn",
"no-return-await": "warn",
"no-script-url": "warn",
"no-self-compare": "warn",
"no-sequences": "warn",
"no-setter-return": "warn",
"no-spaced-func": "warn",
"no-shadow": "error",
"no-tabs": "warn",
"no-template-curly-in-string": "error",
"no-throw-literal": "warn",
"no-undef-init": "error",
"no-undefined": "error",
"no-unmodified-loop-condition": "warn",
"no-unneeded-ternary": "error",
"no-unused-expressions": "warn",
"no-use-before-define": "error",
"no-useless-call": "warn",
"no-useless-computed-key": "warn",
"no-useless-concat": "warn",
"no-useless-constructor": "warn",
"no-useless-rename": "warn",
"no-useless-return": "warn",
"no-var": "warn",
"no-void": "warn",
"no-whitespace-before-property": "error",
"nonblock-statement-body-position": "warn",
"object-curly-spacing": [
"warn",
"always"
],
"object-property-newline": [ "warn", { "allowAllPropertiesOnSameLine": true }],
"object-shorthand": "warn",
"one-var-declaration-per-line": "warn",
"operator-assignment": "warn",
"operator-linebreak": [ "warn", "before" ],
"padding-line-between-statements": "warn",
"padded-blocks": [ "warn", { "switches": "never" }, { "allowSingleLineBlocks": true }],
"prefer-arrow-callback": "warn",
"prefer-const": "warn",
"prefer-destructuring": "warn",
"prefer-exponentiation-operator": "warn",
"prefer-numeric-literals": "warn",
"prefer-object-spread": "error",
"prefer-promise-reject-errors": "warn",
"prefer-regex-literals": "warn",
"prefer-rest-params": "warn",
"prefer-spread": "warn",
"require-jsdoc": "warn",
"require-unicode-regexp": "warn",
"rest-spread-spacing": "warn",
"semi": "error",
"semi-spacing": "warn",
"semi-style": [
"warn",
"last"
],
"space-before-blocks": "warn",
"space-before-function-paren": [ "error", "always" ],
"space-in-parens": [
"warn",
"never"
],
"spaced-comment": [ "warn", "always" ],
"strict": "warn",
"switch-colon-spacing": "warn",
"symbol-description": "warn",
"template-curly-spacing": [
"warn",
"never"
],
"template-tag-spacing": "warn",
"unicode-bom": [
"warn",
"never"
],
"vars-on-top": "warn",
"wrap-iife": "warn",
"wrap-regex": "error",
"yield-star-spacing": "warn",
"yoda": [
"warn",
"never"
]
}
};

View File

@ -1,70 +1,70 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '0 16 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'typescript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ master ]
schedule:
- cron: '0 16 * * 0'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
steps:
- name: Checkout repository
uses: actions/checkout@v3
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@v2
# Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
# and modify them (or add more) to build your code if your project
# uses a compiled language
#- run: |
# make bootstrap
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@ -1,29 +1,29 @@
name: Docker Image CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Login
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
name: Docker Image CI
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Login
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKER_HUB_USERNAME }}/modmail:latest

29
.gitignore vendored
View File

@ -1,16 +1,13 @@
# Env
config.ts
# Node bs
node_modules
# Logs & cache
logs
modmail_cache
persistent_cache.json
canned_replies.json
config.js*
old.eslintrc.js
.yarn/install-state.gz
build
# Env
config.js
# Node bs
node_modules
# Logs & cache
logs
modmail_cache
persistent_cache.json
canned_replies.json
config - server.js
old.eslintrc.js

File diff suppressed because one or more lines are too long

View File

@ -1,5 +0,0 @@
nodeLinker: node-modules
npmRegistryServer: "https://registry.corgi.wtf"
yarnPath: .yarn/releases/yarn-4.1.1.cjs

49
@types/Client.d.ts vendored
View File

@ -1,49 +0,0 @@
import { LoggerMasterOptions } from '@navy.gif/logger';
import { ClientOptions, Message } from 'discord.js';
export type ModmailClientOptions = {
libraryOptions: ClientOptions,
loggerOptions: LoggerMasterOptions,
prefix: string,
discordToken: string,
galacticToken?: string,
mainGuild: string,
bansGuild: string,
modmailCategory: [string, string, string]
context: number,
staffRoles: string[],
graveyardInactive: number,
readInactive: number,
channelSweepInterval: number,
saveInterval: number,
sudo: string[],
anonColor: number,
modmailReminderInterval: number,
modmailReminderChannel: string,
logChannel: string,
inlineResponse: string,
}
export type CommandOptions = {
name: string,
aliases?: string[],
showUsage?: boolean,
usage?: string
}
export type ErrorResponse = {
error: true,
msg: string
}
export type CommandResponse =
| ErrorResponse | Message
| string | void | null
export type CommandParams = {
args: string[],
clean: string
}
export type ExtendedMessage<T extends boolean = boolean> = Message<T>
& {_caller: string}

67
@types/Modmail.d.ts vendored
View File

@ -1,67 +0,0 @@
import { Guild, GuildMember, Message, User } from 'discord.js';
import { ExtendedMessage } from './Client.js';
export type ModmailReadState = 'unread' | 'read'
export type ModmailEntry = {
attachments?: string[],
author: string,
content?: string,
timestamp: number,
isReply?: boolean,
readState?: ModmailReadState,
anon?: boolean,
msgId?: string
}
export type SpamEntry = {
start: number,
count: number,
timeout: boolean,
warned: boolean
}
export type ModmailLogEntry = {
author: User,
content?: string,
action: string,
target?: User
}
export type ModmailSendOptions = {
target: GuildMember,
staff: GuildMember,
anon: boolean,
content: string
}
export type MomdailSendCannedResponseOptions = {
message: ExtendedMessage<true>,
responseName: string,
anon: boolean
}
export type MomdailSendResponseOptions = {
message: ExtendedMessage<true>,
content: string,
anon: boolean
}
export type ModmailSendModmailOptions = {
message: Mesasge<true>,
content: string,
anon: boolean,
target: User | GuildMember
}
export type ChannelHandlerOptions = {
graveyardInactive: number,
readInactive: number,
channelSweepInterval: number,
modmailCategory: string[]
}
export type ModmailTarget = {
struct: GuildMember,
inAppealServer: boolean
}

View File

@ -1,11 +1,7 @@
FROM node:lts-alpine3.19
WORKDIR /modmail
COPY build build
COPY package.json package.json
COPY ./.yarnrc.yml ./.yarnrc.yml
COPY ./.yarn/releases ./.yarn/releases
RUN yarn install
VOLUME [ "/modmail/modmail_cache" ]
# ENTRYPOINT [ "/bin/ash" ]
CMD ["node", "--enable-source-maps", "build/index.js"]
# CMD ["/bin/ash"]
FROM node:lts-alpine3.12
WORKDIR /modmail
COPY . .
RUN yarn install --production
VOLUME [ "/modmail/modmail_cache" ]
#ENTRYPOINT [ "/bin/ash" ]
CMD ["node", "index.js"]

42
config.example.js Normal file
View File

@ -0,0 +1,42 @@
// remove .example from the name to use this file, make sure to fill in the configs
module.exports = {
discordToken: '', // Discord bot token
galacticToken: '', // Token for Galactic's API for integration with Galactic Bot, not a thing yet
mainGuild: '', // main server of operation
bansGuild: '', // optional bans server for potential appeals processing
prefix: '!',
modmailCategory: [], // Should have 3 category IDs (AS STRINGS), main category (new), answered/waiting for reply, graveyard (old modmail channels getting ready for deletion)
context: 10, // How many messages to load for context
staffRoles: [], // Roles that have access to the bot commands
graveyardInactive: 60, // How long a channel should be inactive for in the graveyard before deletion
readInactive: 30, // How long a channel should be inactive for in the read category before moving to graveyard
channelSweepInterval: 10, // How often channel transitions should be processed in minutes
saveInterval: 1, // How often modmail history should be written to file in minutes
sudo: [], // Array of IDs (user or role) that have elevated access to the bot, i.e. eval, disable and any other elevated permission commands
anonColor: 0, // A colour value, 0 will default to the bot's highest coloured role
modmailReminderInterval: 10, // How often the bot should send a reminder of x new modmails in queue
modmailReminderChannel: '', // channel to send reminders in
logChannel: '', // Channel in which modmail logs are sent
inlineResponse: null, // The response the bot gives when a user DMs the bot, null will have the bot use the default
clientOptions: {
intents: [ // Needs at least these
'GUILDS',
'GUILD_MEMBERS',
'DIRECT_MESSAGES'
],
presence: { // Playing status
activity: {
name: 'DM to contact Server Staff',
type: 'PLAYING'
}
}
},
loggerOptions: { // This is for logging errors to a discord webhook
webhook: { // If you're not using the webhook, disable it
disabled: true,
id: '',
token: ''
}
}
};

View File

@ -1,41 +0,0 @@
// remove .example from the name to use this file, make sure to fill in the configs
{
"discordToken": "", // Discord bot token
"galacticToken": "", // Token for Galactic"s API for integration with Galactic Bot, not a thing yet
"mainGuild": "", // main server of operation
"bansGuild": "", // optional bans server for potential appeals processing
"prefix": "!",
"modmailCategory": [], // Should have 3 category IDs (AS STRINGS), main category (new), answered/waiting for reply, graveyard (old modmail channels getting ready for deletion)
"context": 10, // How many messages to load for context
"staffRoles": [], // Roles that have access to the bot commands
"graveyardInactive": 60, // How long a channel should be inactive for in the graveyard before deletion
"readInactive": 30, // How long a channel should be inactive for in the read category before moving to graveyard
"channelSweepInterval": 10, // How often channel transitions should be processed in minutes
"saveInterval": 1, // How often modmail history should be written to file in minutes
"sudo": [], // Array of IDs (user or role) that have elevated access to the bot, i.e. eval, disable and any other elevated permission commands
"anonColor": 0, // A colour value, 0 will default to the bot"s highest coloured role
"modmailReminderInterval": 10, // How often the bot should send a reminder of x new modmails in queue
"modmailReminderChannel": "", // channel to send reminders in
"logChannel": "", // Channel in which modmail logs are sent
"inlineResponse": null, // The response the bot gives when a user DMs the bot, null will have the bot use the default
"clientOptions": {
"intents": [ // Needs at least these
"GUILDS",
"GUILD_MEMBERS",
"DIRECT_MESSAGES"
],
"presence": { // Playing status
"activity": {
"name": "DM to contact Server Staff",
"type": "PLAYING"
}
}
},
"loggerOptions": { // This is for logging errors to a discord webhook
"webhook": { // If you"re not using the webhook, disable it
"disabled": true,
"id": "",
"token": ""
}
}
}

12
index.js Normal file
View File

@ -0,0 +1,12 @@
if (!require('fs').existsSync('./config.js')) {
// eslint-disable-next-line no-console
console.error(`Missing config file.`);
// eslint-disable-next-line no-process-exit
process.exit(0);
}
const Options = require('./config.js');
const { Client } = require('./structure');
const client = new Client(Options);
client.init();

View File

@ -1,17 +0,0 @@
import { readFileSync } from 'node:fs';
import Client from './src/Client.js';
const optionsFile = readFileSync('./config.jsonc', { encoding: 'utf-8' });
const lines = optionsFile.split('\n');
const clean = [];
for (const line of lines)
{
if (line.startsWith('//'))
continue;
clean.push(line.replace(/\/\/.*/u, ''));
}
const options = JSON.parse(clean.join('\n'));
const client = new Client(options);
client.init();

77
logger/Logger.js Normal file
View File

@ -0,0 +1,77 @@
const { createLogger, format, transports: { Console }, config } = require('winston');
const moment = require('moment');
const chalk = require('chalk');
const { DiscordWebhook, FileExtension } = require('./transports/index.js');
const Constants = {
Colors: {
error: 'red',
warn: 'yellow',
info: 'blue',
verbose: 'cyan',
debug: 'magenta',
silly: 'magentaBright'
}
};
class Logger {
constructor (client, options) {
this.client = client;
this.options = options;
const transports = [
new FileExtension({ filename: `logs/${this.date.split(' ')[0]}.log`, level: 'debug' }), // Will NOT log "silly" logs, could change in future.
new FileExtension({ filename: `logs/errors/${this.date.split(' ')[0]}-error.log`, level: 'error' }),
new Console({ level: 'silly' }) // Will log EVERYTHING.
];
if (!options.webhook.disabled) transports.push(new DiscordWebhook({ level: 'error', ...options.webhook })); // Broadcast errors to a discord webhook.
this.logger = createLogger({
levels: config.npm.levels,
format:
format.cli({
colors: Constants.Colors
}),
transports
});
// TODO: Add proper date-oriented filenames and add a daily rotation file (?).
}
write (type = 'silly', string = '') {
const color = Constants.Colors[type];
const header = `${chalk[color](`[${this.date}][modmail]`)}`;
this.logger.log(type, `${header} : ${string}`);
}
get date () {
return moment().format("YYYY-MM-DD hh:mm:ss");
}
info (message) {
this.write('info', message);
}
warn (message) {
this.write('warn', message);
}
error (message) {
this.write('error', message);
}
debug (message) {
this.write('debug', message);
}
}
module.exports = Logger;

3
logger/index.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
Logger: require('./Logger')
};

View File

@ -0,0 +1,52 @@
const Transport = require('winston-transport');
const { WebhookClient } = require('discord.js');
const os = require('os');
const { username } = os.userInfo();
//eslint-disable-next-line no-control-regex
const regex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/gu;
class DiscordWebhook extends Transport {
constructor(opts) {
super(opts);
this.webhookClient = new WebhookClient(
opts.id,
opts.token
);
}
log(info, callback) {
setImmediate(() => {
this.emit('logged', info);
});
const message = info.message.replace(regex, '')
//.replace(new RegExp(options.bot.token, 'gu'), '<redacted>')
.replace(new RegExp(username, 'gu'), '<redacted>');
const developers = [
'nolan',
'navy',
'sema'
];
const random = developers[Math.floor(Math.random() * developers.length)];
const embed = {
color: 0xe88388,
timestamp: new Date(),
description: `\`\`\`${message}\`\`\``,
footer: {
text: `probably ${random}'s fault`
}
};
this.webhookClient.send('', { embeds: [embed] }).catch(() => {
// DO nothing
});
callback();
}
}
module.exports = DiscordWebhook;

View File

@ -0,0 +1,119 @@
/* eslint-disable */
const { transports: { File } } = require('winston');
const diagnostics = require('diagnostics');
const debug = diagnostics('winston:file');
const { MESSAGE } = require('triple-beam');
const moment = require('moment');
const regex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
class FileExtension extends File {
constructor(opts) {
super(opts);
}
log(info, callback = () => {}) {
// Remark: (jcrugzz) What is necessary about this callback(null, true) now
// when thinking about 3.x? Should silent be handled in the base
// TransportStream _write method?
if (this.silent) {
callback();
return true;
}
// Output stream buffer is full and has asked us to wait for the drain event
if (this._drain) {
this._stream.once('drain', () => {
this._drain = false;
this.log(info, callback);
});
return;
}
if (this._rotate) {
this._stream.once('rotate', () => {
this._rotate = false;
this.log(info, callback);
});
return;
}
// Grab the raw string and append the expected EOL.
const output = `${info[MESSAGE]}${this.eol}`.replace(regex, '');
const bytes = Buffer.byteLength(output);
// After we have written to the PassThrough check to see if we need
// to rotate to the next file.
//
// Remark: This gets called too early and does not depict when data
// has been actually flushed to disk.
function logged() {
this._size += bytes;
this._pendingSize -= bytes;
debug('logged %s %s', this._size, output);
this.emit('logged', info);
// Do not attempt to rotate files while opening
if (this._opening) {
return;
}
// Check to see if we need to end the stream and create a new one.
if (!this._needsNewFile()) {
return;
}
// End the current stream, ensure it flushes and create a new one.
// This could potentially be optimized to not run a stat call but its
// the safest way since we are supporting `maxFiles`.
this._rotate = true;
this._endStream(() => this._rotateFile());
}
// Keep track of the pending bytes being written while files are opening
// in order to properly rotate the PassThrough this._stream when the file
// eventually does open.
this._pendingSize += bytes;
if (this._opening
&& !this.rotatedWhileOpening
&& this._needsNewFile(this._size + this._pendingSize)) {
this.rotatedWhileOpening = true;
}
const written = this._stream.write(output, logged.bind(this));
if (!written) {
this._drain = true;
this._stream.once('drain', () => {
this._drain = false;
callback();
});
} else {
callback(); // eslint-disable-line callback-return
}
debug('written', written, this._drain);
this.finishIfEnding();
return written;
}
get date() {
return moment().format("MM-DD-YYYY hh:mm:ss");
}
}
const Constants = {
Colors: {
error: 'red',
warn: 'yellow',
info: 'blue',
verbose: 'cyan',
debug: 'magenta',
silly: 'magentaBright'
}
};
module.exports = FileExtension;

View File

@ -0,0 +1,4 @@
module.exports = {
DiscordWebhook: require('./DiscordWebhook.js'),
FileExtension: require('./FileExtension.js')
};

View File

@ -4,30 +4,23 @@
"main": "index.js",
"author": "Navy <navydotgif@gmail.com>",
"license": "MIT",
"private": false,
"description": "Modmail bot with eventual integration with Galactic Bot's API",
"type": "module",
"scripts": {
"start": "yarn build && node --enable-source-maps build/index.js",
"build": "tsc --build",
"lint": "eslint src/ --fix",
"dev": "nodemon -e js --delay 5 --ignore *.json build/index.js",
"dockerpub": "docker build . --tag navydotgif/modmail:latest && docker push navydotgif/modmail:latest "
"start": "node index.js",
"dev": "nodemon --ignore *.json index.js",
"dockerpub": "tsc && docker build . --tag navydotgif/modmail:latest && docker push navydotgif/modmail:latest "
},
"devDependencies": {
"@babel/core": "^7.24.3",
"@babel/preset-env": "^7.24.3",
"@babel/preset-typescript": "^7.24.1",
"@typescript-eslint/eslint-plugin": "^7.3.1",
"@typescript-eslint/parser": "^7.3.1",
"eslint": "^8.57.0",
"eslint-plugin-node": "^11.1.0",
"eslint": "^7.28.0",
"nodemon": "^2.0.7"
},
"dependencies": {
"@navy.gif/logger": "^2.5.4",
"discord.js": "^14.14.1",
"chalk": "^4.0.0",
"diagnostics": "^2.0.2",
"discord.js": "^12.5.3",
"moment": "^2.29.4",
"typescript": "^5.4.3"
},
"packageManager": "yarn@4.1.1"
"winston": "^3.3.3",
"winston-transport": "^4.4.0"
}
}

View File

@ -1,414 +0,0 @@
import { APIEmbed, CategoryChannel, ChannelType, GuildMember, GuildTextBasedChannel, Message, TextChannel, User } from 'discord.js';
import { ChannelHandlerOptions, ModmailEntry, ModmailReadState, ModmailTarget } from '../@types/Modmail.js';
import ModmailClient from './Client.js';
import Modmail from './Modmail.js';
import { ErrorResponse } from '../@types/Client.js';
class ChannelHandler
{
modmail: Modmail;
client: ModmailClient;
awaitingChannel: {[key: string]: Promise<TextChannel> | null};
categories: string[];
graveyardInactive: number;
readInactive: number;
channelSweepInterval: number;
newMail!: CategoryChannel;
readMail!: CategoryChannel;
graveyard!: CategoryChannel;
sweeper!: NodeJS.Timeout;
constructor (modmail: Modmail, opts: ChannelHandlerOptions)
{
this.modmail = modmail;
this.client = modmail.client;
this.awaitingChannel = {};
this.categories = opts.modmailCategory.map((id) => id.toString());
this.graveyardInactive = opts.graveyardInactive;
this.readInactive = opts.readInactive;
this.channelSweepInterval = opts.channelSweepInterval;
}
protected get logger ()
{
return this.client.logger;
}
protected get cache ()
{
return this.client.cache;
}
get mainServer ()
{
return this.client.mainServer;
}
get bansServer ()
{
return this.client.bansServer;
}
init ()
{
const { channels } = this.mainServer;
this.newMail = channels.resolve(this.categories[0]) as CategoryChannel;
if (!this.newMail || this.newMail.type !== ChannelType.GuildCategory)
throw new Error('Missing new mail category!');
this.readMail = channels.resolve(this.categories[1]) as CategoryChannel;
if (!this.readMail || this.readMail.type !== ChannelType.GuildCategory)
throw new Error('Missing read mail category!');
this.graveyard = channels.resolve(this.categories[2]) as CategoryChannel;
if (!this.graveyard || this.graveyard.type !== ChannelType.GuildCategory)
throw new Error('Missing graveyard category!');
// Sweep graveyard every x min and move stale channels to graveyard
this.sweeper = setInterval(this.sweepChannels.bind(this), this.channelSweepInterval * 60 * 1000);
}
// Send in channel
async send (target: ModmailTarget, embed: APIEmbed, newEntry: ModmailEntry): Promise<ErrorResponse | Message<true>>
{
// Load & update the users past modmails
const history = await this.cache.loadModmailHistory(target.struct.id)
.catch((err) =>
{
this.client.logger.error(`Error during loading of past mail:\n${err.stack}`);
return { error: true };
});
if (!Array.isArray(history) || ('error' in history && history.error))
return {
error: true,
msg: 'Internal error, this has been logged.'
};
const channel = await this.load(target, history).catch(this.client.logger.error.bind(this.client.logger));
if (!channel)
throw new Error(`Unable to load channel for ${target.struct.id}`);
history.push(newEntry);
const sent = await channel.send({ embeds: [ embed ] });
await channel.edit({ parent: this.readMail.id, lockPermissions: true }).catch((err: Error) =>
{
this.client.logger.error(`Error during channel transition:\n${err.stack}`);
});
if (this.cache.queue.includes(target.struct.id))
this.cache.queue.splice(this.cache.queue.indexOf(target.struct.id), 1);
if (!this.cache.updatedThreads.includes(target.struct.id))
this.cache.updatedThreads.push(target.struct.id);
if (this.readMail.children.cache.size > 45)
this.sweepChannels({ count: 5, force: true });
return sent;
}
async setReadState (target: string, channel: GuildTextBasedChannel, staff: User, state: ModmailReadState)
: Promise<ErrorResponse | void>
{
const history = await this.cache.loadModmailHistory(target)
.catch((err) =>
{
this.client.logger.error(`Error during loading of past mail:\n${err.stack}`);
return { error: true };
});
if (!Array.isArray(history) || ('error' in history && history.error))
return {
error: true,
msg: 'Internal error, this has been logged.'
};
if (!history.length)
return {
error: true,
msg: 'User has no modmail history.'
};
if (history[history.length - 1].readState !== state)
history.push({ author: staff.id, timestamp: Date.now(), readState: state }); // To keep track of read state
if (state === 'unread')
{
const user = await this.client.resolveUser(target);
await this.load({ struct: user!, inAppealServer: false }, history);
}
if (channel)
await channel.edit({ parent: state === 'read' ? this.readMail.id : this.newMail.id, lockPermissions: true });
if (!this.cache.updatedThreads.includes(target))
this.cache.updatedThreads.push(target);
if (this.cache.queue.includes(target) && state === 'read')
this.cache.queue.splice(this.cache.queue.indexOf(target), 1);
else if (!this.cache.queue.includes(target) && state === 'unread')
this.cache.queue.push(target);
}
/**
* Process channels for incoming modmail
*
* @param {GuildMember} member
* @param {string} id
* @param {Array<object>} history
* @return {TextChannel}
* @memberof ChannelHandler
*/
load (target: ModmailTarget | { struct: User, inAppealServer?: false }, history: ModmailEntry[])
{
if (this.awaitingChannel[target.struct.id])
return this.awaitingChannel[target.struct.id]!;
// eslint-disable-next-line no-async-promise-executor
const promise = new Promise<TextChannel>(async (resolve, reject) =>
{
const channelID = this.cache.channels[target.struct.id];
const guild = this.mainServer;
const user = target.struct instanceof GuildMember ? target.struct.user : target.struct;
const member = target.struct instanceof GuildMember ? target.struct : null;
let channel = guild.channels.resolve(channelID);
const { context } = this.client;
if (this.newMail.children.cache.size >= 45)
this.overflow();
if (!channel)
{ // Create and populate channel
channel = await guild.channels.create({
name: `${user.username}_${user.discriminator}`,
parent: this.newMail.id
}).catch(err =>
{
this.client.logger.warn(`Failed to create channel for ${user.tag}, trying again.\n${err.stack || err}`);
return null;
});
if (!channel)
channel = await guild.channels.create({
name: `${user.id}`,
parent: this.newMail.id
}).catch((err) =>
{
reject(err);
return null;
});
if (!channel)
return;
// Start with user info embed
const embed: APIEmbed = {
author: { name: user.tag },
thumbnail: {
url: user.displayAvatarURL()
},
fields: [
{
name: '__User Data__',
value: `**User:** <@${user.id}>\n`
+ `**Account created:** ${user.createdAt.toDateString()}\n`,
inline: false
}
],
footer: { text: `• User ID: ${user.id}` },
color: guild.members.me!.roles.highest.color
};
if (member && target.inAppealServer)
{
if (guild.members.me?.permissions.has('BanMembers'))
{
const ban = await guild.bans.fetch(member.id).catch(() => null);
// eslint-disable-next-line max-depth
if (ban)
embed.description = '**__USER IS BANNED FROM MAIN SERVER__**';
else
embed.description = '**__USER IS IN APPEAL SERVER BUT NOT BANNED FROM MAIN__**';
}
else
{
embed.description = '**__USER IS IN APPEAL SERVER__**';
}
}
else if (member)
embed.fields!.push({
name: '__Member Data__',
value: `**Nickname:** ${member.nickname || 'N/A'}\n`
+ `**Server join date:** ${member.joinedAt?.toDateString()}\n`
+ `**Roles:** ${member.roles.cache.filter((r) => r.id !== guild.roles.everyone.id).map((r) => `<@&${r.id}>`).join(' ')}`,
inline: false
});
await channel.send({ embeds: [ embed ] }).catch(err => this.client.logger.error(`ChannelHandler.load errored at channel.send:\n${err.stack}`));
// Load in context
const len = history.length;
for (let i = context < len ? context : len; i > 0; i--)
{
const entry = history[len - i];
if (!entry || entry.readState && [ 'read', 'unread' ].includes(entry.readState))
continue;
// eslint-disable-next-line @typescript-eslint/no-shadow
const user = await this.client.resolveUser(entry.author).catch(this.logger.error.bind(this.logger));
if (!user)
return reject(new Error('Failed to find user'));
const mem = await this.modmail.getMember(user.id).catch(this.logger.error.bind(this.logger));
const mbr = mem?.struct;
// eslint-disable-next-line @typescript-eslint/no-shadow
const embed: APIEmbed = {
footer: {
text: user.id
},
author: {
// eslint-disable-next-line no-nested-ternary
name: user.tag + (entry.anon ? ' (ANON)' : entry.isReply ? ' (STAFF)' : ''),
// eslint-disable-next-line camelcase
icon_url: user.displayAvatarURL()
},
// eslint-disable-next-line no-nested-ternary
description: entry.content && entry.content.length ? entry.content.length > 2000 ? `${entry.content.substring(0, 2000)}...\n\n**Content cut off**` : entry.content : '**__MISSING CONTENT__**',
color: mbr?.roles.highest.color || 0,
fields: [],
timestamp: new Date(entry.timestamp).toISOString()
};
if (entry.attachments && entry.attachments.length)
embed.fields!.push({
name: '__Attachments__',
value: entry.attachments.join('\n').substring(0, 1000)
});
await channel.send({ embeds: [ embed ] }).catch(err => this.client.logger.error(`ChannelHandler.load errored at channel.send:\n${err.stack}`));
}
this.cache.channels[user.id] = channel.id;
}
// Ensure the right category
// if (channel.parentID !== this.newMail.id)
await channel.edit({ parent: this.newMail.id, lockPermissions: true }).catch((err) =>
{
this.client.logger.error(`Error during channel transition:\n${err.stack}`);
});
delete this.awaitingChannel[user.id];
resolve(channel as TextChannel);
});
this.awaitingChannel[target.struct.id] = promise;
return promise;
}
/**
*
*
* @param {object} { age, count, force } age: how long the channel has to be without activity to be deleted, count: how many channels to act on, force: whether to ignore answered status
* @memberof Modmail
*/
async sweepChannels ({ age, count, force = false }: {age?: number, count?: number, force?: boolean} = {})
{
this.client.logger.info('Sweeping graveyard');
const now = Date.now();
if (!this.graveyard)
return this.client.logger.error('Missing graveyard category!');
const graveyardChannels = this.graveyard.children.cache.sort((a, b) =>
{
if (a.type !== ChannelType.GuildText || b.type !== ChannelType.GuildText)
return 0;
if (!a.lastMessage)
return -1;
if (!b.lastMessage)
return 1;
return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp;
}).map(ch => ch) as TextChannel[];
let channelCount = 0;
if (!age)
age = this.graveyardInactive * 60 * 1000;
for (const channel of graveyardChannels)
{
const { lastMessage } = channel;
if (!lastMessage || now - lastMessage.createdTimestamp > age || count && channelCount <= count)
{
await channel.delete().then((ch) =>
{
const chCache = this.cache.channels;
const _cached = Object.entries(chCache).find(([ , val ]) =>
{
return val === ch.id;
});
if (_cached)
{
const [ userId ] = _cached;
delete this.cache.channels[userId];
}
}).catch((err) =>
{
this.client.logger.error(`Failed to delete channel ${channel?.id || channel} from graveyard during sweep:\n${err.stack}`);
});
channelCount++;
}
}
this.client.logger.info(`Swept ${channelCount} channels from graveyard, cleaning up answered...`);
if (!this.readMail)
return this.client.logger.error('Missing read mail category!');
const answered = this.readMail.children.cache
.filter((channel) =>
{
if (channel.type !== ChannelType.GuildText)
return false;
return !channel.lastMessage
|| channel.lastMessage.createdTimestamp < Date.now() - this.readInactive * 60 * 1000
|| force;
})
.sort((a, b) =>
{
if (a.type !== ChannelType.GuildText || b.type !== ChannelType.GuildText)
return 0;
if (!a.lastMessage)
return -1;
if (!b.lastMessage)
return 1;
return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp;
}).map(c => c);
let chCount = this.graveyard.children.cache.size;
for (const ch of answered)
{
if (chCount < 50)
{
await ch.edit({ parent: this.graveyard.id, lockPermissions: true }).catch((err) =>
{
this.client.logger.error(`Failed to move channel to graveyard during sweep:\n${err.stack}`);
});
chCount++;
}
else
break;
}
this.client.logger.info(`Sweep done. Took ${Date.now() - now}ms`);
}
async overflow ()
{ // Overflows new modmail category into read
const channels = this.newMail.children.cache.sort((a, b) =>
{
if (a.type !== ChannelType.GuildText || b.type !== ChannelType.GuildText)
return 0;
if (!a.lastMessage)
return -1;
if (!b.lastMessage)
return 1;
return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp;
}).map(c => c);
if (this.readMail.children.cache.size >= 45)
await this.sweepChannels({ count: 5, force: true });
let counter = 0;
for (const channel of channels)
{
await channel.edit({ parent: this.readMail.id, lockPermissions: true });
counter++;
if (counter === 5)
break;
}
}
}
export default ChannelHandler;

View File

@ -1,296 +0,0 @@
import { inspect } from 'node:util';
import { Channel, Client, Guild, Message, Partials, TextBasedChannel, User } from 'discord.js';
import { CommandResponse, ErrorResponse, ExtendedMessage, ModmailClientOptions } from '../@types/Client.js';
import { MasterLogger as Logger } from '@navy.gif/logger';
import Registry from './Registry.js';
import Resolver from './Resolver.js';
import JsonCache from './JsonCache.js';
import Modmail from './Modmail.js';
class ModmailClient extends Client
{
#ready: boolean;
#options: ModmailClientOptions;
#prefix: string;
#logger: Logger;
#registry: Registry;
#mainServer!: Guild;
#bansServer?: Guild | null;
#resolver: Resolver;
#cache: JsonCache;
#modmail: Modmail;
constructor (options: ModmailClientOptions)
{
const partials: number[] = [];
for (const partial of options.libraryOptions?.partials || [])
{
if (partial in Partials)
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
partials.push(Partials[partial]);
else
throw new Error(`Invalid partial ${partial}`);
}
super({ ...options.libraryOptions, partials });
this.#options = options;
this.#ready = false;
this.#prefix = options.prefix;
this.#logger = new Logger(options.loggerOptions);
this.#registry = new Registry(this);
this.#resolver = new Resolver(this);
this.#cache = new JsonCache(this, options.saveInterval);
this.#modmail = new Modmail(this);
this.on('ready', () =>
{
this.#logger.info(`Client ready, logged in as ${this.user!.tag}`);
});
}
async init ()
{
this.#registry.loadCommands();
this.on('messageCreate', this.handleMessage.bind(this));
this.#cache.load();
this.#logger.info('Logging in');
const promise = this.ready();
await this.login(this.#options.discordToken);
await promise;
this.#mainServer = this.guilds.cache.get(this.#options.mainGuild)!;
if (!this.#mainServer)
throw new Error('Missing main server from discord');
this.#bansServer = this.guilds.cache.get(this.#options.bansGuild) ?? null;
this.#logger.info('Starting up modmail handler');
await this.#modmail.init();
process.on('exit', () =>
{
this.#logger.warn('process exiting');
this.#cache.savePersistentCache();
this.#cache.saveModmailHistory();
});
process.on('SIGINT', () =>
{
this.#logger.warn('received sigint');
// this.cache.save();
// this.cache.saveModmailHistory(this.modmail);
// eslint-disable-next-line no-process-exit
process.exit();
});
process.on('unhandledRejection', (reason, prom) =>
{
this.#logger.error(`Unhandled promise rejection at: ${inspect(prom)}\nReason: ${reason}`);
});
this.#ready = true;
await this.#modmail.reminderChannel?.send('Modmail bot booted and ready.');
}
ready (): Promise<void>
{
return new Promise((resolve) =>
{
if (this.#ready)
return resolve();
this.once('ready', () => resolve());
});
}
async handleMessage (msg: Message): Promise<void>
{
const message = msg as ExtendedMessage;
if (!this.#ready)
return;
if (message.author.bot)
return;
// No command handling in dms, at least for now
if (!message.guild)
{
try
{
return void this.#modmail.handleUser(message);
}
catch (err)
{
const error = err as Error;
this.#logger.error(`Error during user handle:\n${error.stack}`);
return;
}
}
const prefix = this.#prefix;
const { channel, guild, content, member } = message;
if (![ this.#mainServer.id, this.#bansServer?.id || '0' ].includes(guild.id))
return;
if (!content || !content.startsWith(prefix))
return;
const roles = member?.roles.cache.map((r) => r.id) ?? [];
if (!member || !roles.some((r) => this.#options.staffRoles.includes(r)) && !member.permissions.has('Administrator'))
return;
const [ rawCommand, ...args ] = content.split(/\s+/u);
const commandName = rawCommand.substring(prefix.length);
const command = this.#registry.find(commandName);
if (!command)
return;
message._caller = commandName;
if (command.showUsage && !args.length)
{
let helpStr = `**${command.name}**\nUsage: ${this.#prefix}${command.name} ${command.usage}`;
if (command.aliases)
helpStr += `\nAliases: ${command.aliases.join(', ')}`;
return void channel.send(helpStr).catch(err => this.logger.error(`Client.handleMessage errored at channel.send:\n${err.stack}`));
}
this.logger.debug(`${message.author.tag} is executing command ${command.name}`);
const clean = message.content.replace(`${this.#prefix}${commandName}`, '').trim();
let result: CommandResponse = null;
try
{
result = await command.execute(message, { args: [ ...args ], clean });
}
catch (err)
{
const error = err as Error;
this.logger.error(`Command ${command.name} errored during execution:\nARGS: [ "${args.join('", "')}" ]\n${error.stack}`);
result = {
error: true,
msg: `Command ${command.name} ran into an error during execution. This has been logged.`
};
}
if (!result || result instanceof Message)
return;
if (typeof result === 'string')
return void channel.send(result).catch(err => this.logger.error(`${err.stack}\n${inspect(result)}`));
else if (result.error)
return void channel.send(result.msg).catch(err => this.logger.error(`${err.stack}\n${inspect(result)}`));
// else if (result.response)
// return channel.send(result.response).catch(err => this.logger.error(`${err.stack}\n${inspect(result)}`));
}
resolveUser (...args: [resolveable: string, force?: boolean])
{
return this.#resolver.resolveUser(...args);
}
resolveUsers (...args: [resolveable: string[], force?: boolean])
{
return this.#resolver.resolveUsers(...args);
}
resolveChannels (...args: [resolveable: string, strict?: boolean, guild?: Guild, filter?: (channel: Channel) => boolean])
{
return this.#resolver.resolveChannels(...args);
}
resolveChannel (...args: [resolveable: string, strict?: boolean, guild?: Guild, filter?: (channel: Channel) => boolean])
{
return this.#resolver.resolveChannel(...args);
}
async prompt (str: string, { author, channel, time }: {author: User, channel?: TextBasedChannel, time?: number})
{
if (!channel && author)
channel = await author.createDM();
if (!channel)
throw new Error('Missing channel for prompt, must pass at least author.');
await channel.send(str).catch(err => this.logger.error(`Client.prompt errored at channel.send:\n${err.stack}`));
return channel.awaitMessages({ filter: (m) => m.author.id === author.id, max: 1, time: time || 30000, errors: [ 'time' ] })
.then((collected) =>
{
return collected.first();
})
.catch(() => null);
}
getUserFromChannel (channel: Channel): ErrorResponse | [userId: string, channel: string]
{
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) =>
{
return val === channel.id;
});
if (!result)
return {
error: true,
msg: 'This doesn\'t seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**'
};
return result;
}
get prefix ()
{
return this.#prefix;
}
get logger ()
{
return this.#logger;
}
get cache ()
{
return this.#cache;
}
get mainServer ()
{
return this.#mainServer;
}
get bansServer ()
{
return this.#bansServer;
}
get context ()
{
return this.#options.context;
}
get modmail ()
{
return this.#modmail;
}
get sudo ()
{
return this.#options.sudo;
}
get modmailOpts ()
{
const opts = this.#options;
return {
anonColor: opts.anonColor,
modmailReminderInterval: opts.modmailReminderInterval,
modmailReminderChannel: opts.modmailReminderChannel,
logChannel: opts.logChannel,
modmailCategory: opts.modmailCategory,
inlineResponse: opts.inlineResponse,
graveyardInactive: opts.graveyardInactive,
readInactive: opts.readInactive,
channelSweepInterval: opts.channelSweepInterval,
};
}
}
export default ModmailClient;

View File

@ -1,31 +0,0 @@
import { CommandOptions, CommandParams, CommandResponse, ExtendedMessage } from '../@types/Client.js';
import ModmailClient from './Client.js';
class Command
{
name: string;
aliases: string[];
client: ModmailClient;
showUsage: boolean;
usage: string;
constructor (client: ModmailClient, options: CommandOptions)
{
this.name = options.name;
this.aliases = options.aliases ?? [];
this.showUsage = options.showUsage ?? false;
this.usage = options.usage ?? '';
if (!this.name)
throw new Error('Missing name for command');
this.client = client;
}
execute (_message?: ExtendedMessage, _options?: CommandParams): Promise<CommandResponse> | CommandResponse
{
throw new Error(`Missing execute in ${this.name}`);
}
}
export default Command;

View File

@ -1,189 +0,0 @@
import fs from 'node:fs';
import pathUtil from 'node:path';
import { MasterLogger } from '@navy.gif/logger';
import ModmailClient from './Client.js';
import Cache from './abstractions/Cache.js';
import Util from './Util.js';
import { ModmailEntry } from '../@types/Modmail.js';
class JsonCache extends Cache
{
#logger: MasterLogger;
#saveInterval: number;
#ready: boolean;
#modmail: {[key: string]: ModmailEntry[]};
#updatedThreads: string[];
cacheSaveInterval?: NodeJS.Timeout;
modmailSaveInterval?: NodeJS.Timeout;
constructor (client: ModmailClient, saveInterval: number)
{
super(client);
this.#logger = client.logger;
this.#saveInterval = saveInterval;
this.#ready = false;
// Data that gets stored to persistent cache
this.queue = [];
this.channels = {};
this.lastActivity = {};
this.misc = {}; // Random misc data, should not be non-primitive data types
// Stored separately if at all
this.#modmail = {};
this.#updatedThreads = [];
}
get ready ()
{
return this.#ready;
}
override load ()
{
if (this.#ready)
return;
if (fs.existsSync('./persistent_cache.json'))
{
this.#logger.info('Loading cache');
const raw = JSON.parse(fs.readFileSync('./persistent_cache.json', { encoding: 'utf-8' }));
this.queue = raw.queue;
this.channels = raw.channels;
this.lastActivity = raw.lastActivity;
this.misc = raw.misc;
}
else
{
this.#logger.info('Cache file missing, creating...');
this.savePersistentCache();
}
this.cacheSaveInterval = setInterval(this.savePersistentCache.bind(this), 10 * 60 * 1000);
this.modmailSaveInterval = setInterval(this.saveModmailHistory.bind(this), this.#saveInterval * 60 * 1000);
this.#ready = true;
}
override savePersistentCache ()
{
this.#logger.debug('Saving cache');
fs.writeFileSync('./persistent_cache.json', JSON.stringify(this.json, null, 4));
}
override saveModmailHistory ()
{
if (!this.#updatedThreads.length)
return;
const toSave = [ ...this.#updatedThreads ];
this.#updatedThreads = [];
this.logger.debug('Saving modmail data');
if (!fs.existsSync('./modmail_cache'))
fs.mkdirSync('./modmail_cache');
for (const id of toSave)
{
const path = `./modmail_cache/${id}.json`;
try
{
fs.writeFileSync(path, JSON.stringify(this.#modmail[id], null, 4));
}
catch (err)
{
const error = err as Error;
this.logger.error(`Error during saving of history\n${id}\n${JSON.stringify(this.#modmail)}\n${error.stack}`);
}
}
}
override loadModmailHistory (userId: string): Promise<ModmailEntry[]>
{
return new Promise((resolve, reject) =>
{
if (this.#modmail[userId])
return resolve(this.#modmail[userId]);
const path = `./modmail_cache/${userId}.json`;
if (!fs.existsSync(path))
{
this.#modmail[userId] = [];
return resolve(this.#modmail[userId]);
}
fs.readFile(path, { encoding: 'utf-8' }, (err, data) =>
{
if (err)
reject(err);
const parsed = JSON.parse(data);
if (!Util.isModmailHistory(parsed))
throw new Error('Loaded data is not valid modmail history');
this.#modmail[userId] = parsed;
resolve(parsed);
});
});
}
override async verifyQueue ()
{
this.logger.info('Verifying modmail queue.');
for (const entry of this.queue)
{
const path = `./modmail_cache/${entry}.json`;
if (!fs.existsSync(pathUtil.resolve(path)))
this.logger.warn(`User ${entry} is in queue but is missing history. Attempting to recover history.`);
const user = await this.client.resolveUser(entry);
if (!user)
continue;
const dm = await user.createDM();
const messagesColl = await dm.messages.fetch();
let messages = messagesColl.sort((a, b) => a.createdTimestamp - b.createdTimestamp).map(msg => msg); // .filter(msg => msg.author.id !== this.client.user.id)
const amt = messages.length;
const history = await this.loadModmailHistory(entry);
if (history.length)
{ // Sync user's past messages with the bot's cache if one exists
const last = history[history.length - 1];
let index = amt - 1;
for (index; index >= 0; index--)
{ // Find the most recent message that is also in the user's history
const msg = messages[index];
// eslint-disable-next-line max-depth
if (msg.content === last.content || (msg.embeds.length && msg.author.bot))
{
messages = messages.slice(index+1).filter(m => !m.author.bot);
break;
}
}
if (messages.length)
this.logger.warn(`User ${entry} has previous history but is out of sync, attempting sync. ${messages.length} messages out of sync.`);
else
continue;
}
history.push({ attachments: [], timestamp: Date.now(), author: this.client.user!.id, content: 'Attempted a recovery of missing messages at this point, messages may look out of place if something went wrong.' });
for (const { author, content, createdTimestamp, attachments } of messages)
{
if (author.bot)
continue;
history.push({ attachments: attachments.map(att => att.url), author: author.id, content, timestamp: createdTimestamp });
}
this.#updatedThreads.push(entry);
}
this.logger.info('Queue verified.');
this.saveModmailHistory();
}
}
export default JsonCache;

View File

@ -1,592 +0,0 @@
import fs from 'node:fs';
import ModmailClient from './Client.js';
import ChannelHandler from './ChannelHandler.js';
import { APIEmbed, ChannelType, GuildMember, Message, TextBasedChannel, TextChannel } from 'discord.js';
import { ModmailLogEntry, ModmailSendModmailOptions, ModmailSendOptions, ModmailTarget, MomdailSendCannedResponseOptions, MomdailSendResponseOptions, SpamEntry } from '../@types/Modmail.js';
import { ErrorResponse, ExtendedMessage } from '../@types/Client.js';
class Modmail
{
#client: ModmailClient;
#logChannel: TextBasedChannel | null;
#reminderChannel: TextBasedChannel | null;
#anonColor: number;
#reminderInterval: number;
#reminderChannelId: string | null;
#logChannelId: string | null;
#categories: [string, string, string];
#inlineResponse: string;
#updatedThreads: string[];
#queue: string[];
#spammers: {[key: string]: SpamEntry};
#replies: {[key: string]: string};
#lastReminder: Message | null;
#disabled: boolean;
#disabledReason: string | null;
#channels: ChannelHandler;
#channelCache: {[key: string]: TextChannel};
#ready: boolean;
#reminder?: NodeJS.Timeout;
// A lot of this can probably be simplified but I wrote all of this in 2 days and I cba to fix this atm
// TODO: Fix everything
constructor (client: ModmailClient)
{
this.#client = client;
this.#logChannel = null;
this.#reminderChannel = null;
const opts = client.modmailOpts;
this.#anonColor = opts.anonColor;
this.#reminderInterval = opts.modmailReminderInterval || 30;
this.#reminderChannelId = opts.modmailReminderChannel || null;
this.#logChannelId = opts.logChannel || null;
this.#categories = opts.modmailCategory;
this.#inlineResponse = opts.inlineResponse || 'Thank you for your message, we\'ll get back to you soon!';
this.#updatedThreads = [];
this.#queue = [];
this.#spammers = {};
this.#replies = {};
this.#channelCache = {};
this.#lastReminder = null;
this.#disabled = false;
this.#disabledReason = null;
this.#channels = new ChannelHandler(this, opts);
this.#ready = false;
}
protected get mainServer ()
{
return this.#client.mainServer;
}
protected get bansServer ()
{
return this.#client.bansServer;
}
protected get cache ()
{
return this.#client.cache;
}
get reminderChannel ()
{
return this.#reminderChannel;
}
get client ()
{
return this.#client;
}
get ready ()
{
return this.#ready;
}
get replies ()
{
return this.#replies;
}
get queue ()
{
return this.#queue;
}
async init ()
{
if (!this.#anonColor)
this.#anonColor = this.mainServer.members.me!.roles.highest.color;
this.#replies = this.loadReplies();
this.#queue = this.cache.queue;
if (this.#reminderChannelId)
{
const reminderChannel = this.#client.channels.resolve(this.#reminderChannelId);
if (reminderChannel && !reminderChannel.isTextBased)
throw new Error('Reminder channel must be text based');
this.#reminderChannel = reminderChannel as TextBasedChannel;
this.#reminder = setInterval(this.sendReminder.bind(this), this.#reminderInterval * 60 * 1000);
if (this.cache.misc.lastReminder)
this.#lastReminder = await this.#reminderChannel.messages.fetch(this.cache.misc.lastReminder).catch(() => null);
this.sendReminder();
}
if (this.#logChannelId)
{
const channel = this.#client.channels.resolve(this.#logChannelId);
if (channel && !channel.isTextBased)
throw new Error('Log channel must be text based');
this.#logChannel = channel as TextBasedChannel;
}
let logStr = `Started modmail handler for ${this.mainServer.name}`;
if (this.bansServer)
logStr += ` with ${this.bansServer.name} for ban appeals`;
this.#client.logger.info(logStr);
// this.client.logger.info(`Fetching messages from discord for modmail`);
// TODO: Fetch messages from discord in modmail channels
this.#disabled = this.cache.misc.disabled || false;
this.#disabledReason = this.cache.misc.disabledReason || null;
this.#channels.init();
this.#ready = true;
}
async close ()
{
clearTimeout(this.#reminder);
}
async getMember (user: string): Promise<ModmailTarget | null>
{
let inAppealServer = false;
let target: GuildMember | null = this.mainServer.members.cache.get(user) ?? null;
if (!target)
target = await this.mainServer.members.fetch(user).catch(() => null);
if (!target && this.bansServer)
{
target = this.bansServer.members.cache.get(user) ?? null;
if (!target)
target = await this.bansServer.members.fetch(user).catch(() =>
{
return null;
});
if (target)
inAppealServer = true;
}
if (!target)
return null;
return { struct: target, inAppealServer };
}
async getUser (user: string)
{
let result = this.#client.users.cache.get(user) ?? null;
if (!result)
result = await this.#client.users.fetch(user).catch(() => null);
return result;
}
async handleUser (message: Message): Promise<Message | void>
{
const { author, content } = message;
const target = await this.getMember(author.id);
if (!target)
return; // No member object found in main or bans server?
const now = Math.floor(Date.now() / 1000);
// Anti spam -- never seen user
if (!this.#spammers[author.id])
this.#spammers[author.id] = {
start: now, // when counting started
count: 1, // # messages
timeout: false, // timed out?
warned: false // warned?
};
else if (this.#spammers[author.id].timeout)
{ // User was timed out, check if 5 minutes have passsed, if so, reset their timeout else ignore them
if (now - this.#spammers[author.id].start > 5 * 60)
this.#spammers[author.id] = { start: now, count: 1, timeout: false, warned: false };
else
return;
}
else if (this.#spammers[author.id].count > 5 && now - this.#spammers[author.id].start < 15)
{
// Has sent more than 5 messages in less than 15 seconds at this point, time them out
this.#spammers[author.id].timeout = true;
if (!this.#spammers[author.id].warned)
{ // Let them know they've been timed out, toggle the warned property so it doesn't send the warning every time
this.#spammers[author.id].warned = true;
await author.send('I\'ve blocked you for spamming, please try again in 5 minutes');
if (this.#channelCache[author.id])
await this.#channelCache[author.id].send(`I've blocked ${author.tag} from DMing me as they were spamming.`);
}
}
else if (now - this.#spammers[author.id].start > 15)
this.#spammers[author.id] = { start: now, count: 1, timeout: false, warned: false }; // Enough time has passed, reset the object
else
this.#spammers[author.id].count++;
if (this.#disabled)
{
let reason = 'Modmail has been disabled for the time being';
if (this.#disabledReason)
reason += ` for the following reason:\n\n${this.#disabledReason}`;
else
reason += '.';
return author.send(reason);
}
const lastActivity = this.cache.lastActivity[author.id];
if (!lastActivity || now - lastActivity > 30 * 60)
{ // No point in sending this for *every* message
await author.send(this.#inlineResponse);
}
this.cache.lastActivity[author.id] = now;
const pastModmail = await this.cache.loadModmailHistory(author.id)
.catch((err) =>
{
this.#client.logger.error(`Error during loading of past mail:\n${err.stack}`);
return { error: true };
});
if (!Array.isArray(pastModmail) || ('error' in pastModmail && pastModmail.error))
return author.send('Internal error, this has been logged.');
const channel = await this.#channels.load(target, pastModmail)
.catch((err: Error) =>
{
this.#client.logger.error(`Error during channel handling:\n${err.stack || err}`);
return { error: true };
});
if (!(channel instanceof TextChannel) || 'error' in channel && channel.error)
return author.send('Internal error, this has been logged.');
if (channel.type !== ChannelType.GuildText)
throw new Error('Wrong channel type');
if (!this.#channelCache)
this.#channelCache = {};
this.#channelCache[author.id] = channel;
const embed: APIEmbed = {
footer: {
text: target.struct.id
},
author: {
name: target.struct.user.tag,
// eslint-disable-next-line camelcase
icon_url: target.struct.user.displayAvatarURL()
},
// eslint-disable-next-line no-nested-ternary
description: content && content.length ? content.length > 2000 ? `${content.substring(0, 2000)}...\n\n**Content cut off**` : content : '**__MISSING CONTENT__**',
color: target.struct?.roles.highest.color,
fields: [],
timestamp: new Date().toISOString()
};
const attachments = message.attachments.map((att) => att.url);
if (message.attachments.size)
{
embed.fields!.push({
name: '__Attachments__',
value: attachments.join('\n').substring(0, 1000)
});
}
pastModmail.push({ attachments, author: author.id, content, timestamp: Date.now(), isReply: false, msgId: message.id });
if (!this.#updatedThreads.includes(author.id))
this.#updatedThreads.push(author.id);
if (!this.#queue.includes(author.id))
this.#queue.push(author.id);
this.log({ author, action: `${author.tag} (${author.id}) sent new modmail`, content });
await channel.send({ embeds: [ embed ] }).catch((err: Error) =>
{
this.#client.logger.error(`channel.send errored:\n${err.stack}\nContent: "${content}"`);
});
}
async sendCannedResponse ({ message, responseName, anon }: MomdailSendCannedResponseOptions): Promise<ErrorResponse | Message | void>
{
const content = this.getCanned(responseName);
if (!content)
return {
error: true,
msg: `No canned reply by the name \`${responseName}\` exists`
};
return this.sendResponse({ message, content, anon });
}
// Send reply from channel
async sendResponse ({ message, content, anon }: MomdailSendResponseOptions): Promise<ErrorResponse | Message | void>
{
const { channel, member, author } = message;
if (!channel || !channel.parentId)
throw new Error('Either invalid channel or missing');
if (!member)
throw new Error('Missing author member');
if (!this.#categories.includes(channel.parentId))
return {
error: true,
msg: 'This command only works in modmail channels.'
};
// Resolve target user from cache
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) =>
{
return val === channel.id;
});
if (!result)
return {
error: true,
msg: 'This doesn\'t seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**'
};
// Ensure target exists, this should never run into issues
const [ userId ] = result;
const targetMember = await this.getMember(userId);
if (!targetMember)
return {
error: true,
msg: 'User seems to have left.\nReport this if the user is still present.'
};
this.log({ author, action: `${author.tag} replied to ${targetMember.struct.user.tag}`, content, target: targetMember.struct.user });
await message.delete().catch(this.#client.logger.warn.bind(this.#client.logger));
return this.send({ target: targetMember.struct, staff: member, content, anon }).catch((err) => this.#client.logger.error(`Error during Modmail.send:\n${err.stack}`));
}
// Send modmail with the modmail command
async sendModmail ({ message, content, anon, target }: ModmailSendModmailOptions): Promise<ErrorResponse | void>
{
const targetMember = await this.getMember(target.id);
if (!targetMember)
return {
error: true,
msg: 'Cannot find member.'
};
const { member: staff, author } = message;
// Send to channel in server & target
const sent = await this.send({ target: targetMember.struct, staff, anon, content });
if ('error' in sent && sent.error)
return sent;
// Inline response
await message.channel.send('Delivered.').catch((err: Error) => this.#client.logger.error(`Error during Modmail.sendModmail:\n${err.stack}`));
this.log({ author, action: `${author.tag} sent a message to ${targetMember.struct.user.tag}`, content, target: targetMember.struct.user });
}
async send ({ target, staff, anon, content }: ModmailSendOptions): Promise<ErrorResponse | Message<true>>
{
const embed = {
author: {
name: anon ? `${this.mainServer.name.toUpperCase()} STAFF` : staff.user.tag,
// eslint-disable-next-line camelcase
icon_url: anon ? this.mainServer.iconURL()! : staff.user.displayAvatarURL()
},
description: content,
color: anon ? this.#anonColor : staff.roles.highest.color
};
// Dm the user
const sent = await target.send({ embeds: [ embed ] }).catch((err) =>
{
this.#client.logger.warn(`Error during DMing user: ${err.message}`);
return {
error: true,
msg: 'Failed to send message to target.'
} as ErrorResponse;
});
if ('error' in sent && sent.error)
return sent;
if (anon)
embed.author = {
name: `${staff.user.tag} (ANON)`,
// eslint-disable-next-line camelcase
icon_url: staff.user.displayAvatarURL()
};
return this.#channels.send(
{ struct: target, inAppealServer: false },
embed, { author: staff.id, content, timestamp: Date.now(), isReply: true, anon }
);
}
async changeReadState (message: ExtendedMessage<true>, args: string[], state: 'read' | 'unread' = 'read')
: Promise<string | ErrorResponse>
{
const { author } = message;
if ((!message.channel.parentId || !this.#categories.includes(message.channel.parentId)) && !args.length)
return {
error: true,
msg: 'This command only works in modmail channels without arguments.'
};
let response = null,
user = null;
if (args.length)
{
// Eventually support marking several threads read at the same time
const [ id ] = args;
user = await this.#client.resolveUser(id, true);
let channel = await this.#client.resolveChannel(id);
if (channel && channel.type === ChannelType.GuildText)
{
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) =>
{
return val === channel!.id;
});
if (!result)
return {
error: true,
msg: 'That doesn\'t seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**'
};
user = await this.#client.resolveUser(result[0]);
response = await this.#channels.setReadState(user!.id, channel, author, state);
}
else if (user)
{
const _ch = this.cache.channels[user.id];
if (_ch)
channel = await this.#client.resolveChannel(_ch);
if (channel?.type !== ChannelType.GuildText)
throw new Error('Invalid channel type');
response = await this.#channels.setReadState(user.id, channel, author, state);
}
else
{
return `Could not resolve ${id} to a target.`;
}
}
if (!response)
{
const { channel } = message;
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) =>
{
return val === channel.id;
});
if (!result)
return {
error: true,
msg: 'This doesn\'t seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**'
};
const [ userId ] = result;
user = await this.getUser(userId);
response = await this.#channels.setReadState(userId, channel, author, state);
}
if (response && response.error)
return response;
this.log({ author, action: `${author.tag} marked ${user!.tag}'s thread as ${state}`, target: user! });
return 'Done';
}
async sendReminder (): Promise<void>
{
await this.cache.verifyQueue();
const channel = this.#reminderChannel;
const amount = this.#queue.length;
if (!channel)
return;
if (!amount)
{
if (this.#lastReminder)
{
await this.#lastReminder.delete().catch(() => null);
this.#lastReminder = null;
}
return;
}
const str = `${amount} modmail in queue.`;
this.#client.logger.debug(`Sending modmail reminder, #mm: ${amount}`);
if (this.#lastReminder)
{
if (channel.lastMessage?.id === this.#lastReminder?.id)
return void this.#lastReminder.edit(str);
await this.#lastReminder.delete();
}
this.#lastReminder = await channel.send(str);
this.cache.misc.lastReminder = this.#lastReminder.id;
}
async log ({ author, content, action, target }: ModmailLogEntry)
{
if (!this.#logChannel)
return;
const embed: APIEmbed = {
author: {
name: action,
// eslint-disable-next-line camelcase
icon_url: author.displayAvatarURL()
},
description: content ? `\`\`\`${content}\`\`\`` : '',
color: this.mainServer.members.me!.roles.highest.color
};
if (target)
{
embed.footer = {
text: `Staff: ${author.id} | Target: ${target.id}`
};
}
this.#logChannel.send({ embeds: [ embed ] }).catch((err) => this.#client.logger.error(`Error during logging of modmail:\n${err.stack}`));
}
getCanned (name: string)
{
return this.#replies[name.toLowerCase()];
}
loadReplies ()
{
this.#client.logger.info('Loading canned replies');
if (!fs.existsSync('./canned_replies.json'))
return {};
return JSON.parse(fs.readFileSync('./canned_replies.json', { encoding: 'utf-8' }));
}
saveReplies ()
{
this.#client.logger.info('Saving canned replies');
fs.writeFileSync('./canned_replies.json', JSON.stringify(this.#replies));
}
disable (reason: string)
{
this.#disabled = true;
if (reason)
this.#disabledReason = reason;
else
this.#disabledReason = null;
this.cache.misc.disabled = true;
this.cache.misc.disabledReason = this.#disabledReason;
this.cache.savePersistentCache();
}
enable ()
{
this.#disabled = false;
this.cache.misc.disabled = false;
this.cache.savePersistentCache();
}
}
export default Modmail;

View File

@ -1,59 +0,0 @@
import path from 'node:path';
import fs from 'node:fs';
import { Collection } from 'discord.js';
import ModmailClient from './Client.js';
import Command from './Command.js';
class Registry
{
#client: ModmailClient;
#commands: Collection<string, Command>;
constructor (client: ModmailClient)
{
this.#client = client;
this.#commands = new Collection();
}
private get logger ()
{
return this.#client.logger;
}
find (name: string)
{
return this.#commands.find((c) => c.name === name.toLowerCase() || c.aliases?.includes(name.toLowerCase()));
}
async loadCommands ()
{
const commandsDir = path.join(process.cwd(), 'build', 'src', 'commands');
const files = fs.readdirSync(commandsDir);
for (const file of files)
{
const commandPath = path.join(commandsDir, file);
const imported = await import(`file://${commandPath}`);
const commandClass = imported.default;
if (typeof commandClass !== 'function')
{
this.logger.warn(`Attempted to instantiate invalid class at ${file}`);
continue;
}
const command = new commandClass(this.#client);
if (this.#commands.has(command.name))
this.logger.warn(`Command by name ${command.name} already exists, skipping duplicate at path ${commandPath}`);
else
this.#commands.set(command.name, command);
}
}
}
export default Registry;

View File

@ -1,156 +0,0 @@
import { Channel, Guild } from 'discord.js';
import ModmailClient from './Client.js';
class Resolver
{
#client: ModmailClient;
constructor (client: ModmailClient)
{
this.#client = client;
}
/**
* Resolve several user resolveables
*
* @param {Array<String>} [resolveables=[]] an array of user resolveables (name, id, tag)
* @param {Boolean} [strict=false] whether or not to attempt resolving by partial usernames
* @returns {Promise<Array<User>> || boolean} Array of resolved users or false if none were resolved
* @memberof Resolver
*/
async resolveUsers (resolveables: string[] | string, strict = false)
{
if (typeof resolveables === 'string')
resolveables = [ resolveables ];
if (resolveables.length === 0)
return [];
const { users } = this.#client;
const resolved = [];
for (const resolveable of resolveables)
{
if ((/<@!?([0-9]{17,21})>/u).test(resolveable))
{
const [ , id ] = resolveable.match(/<@!?([0-9]{17,21})>/u)!;
const user = await users.fetch(id).catch(() => null);
if (user)
resolved.push(user);
}
else if ((/(id:)?([0-9]{17,21})/u).test(resolveable))
{
const [ , , id ] = resolveable.match(/(id:)?([0-9]{17,21})/u)!;
const user = await users.fetch(id).catch(() => null);
if (user)
resolved.push(user);
}
else if ((/^@?([\S\s]{1,32})#([0-9]{4})/u).test(resolveable))
{
const m = resolveable.match(/^@?([\S\s]{1,32})#([0-9]{4})/u)!;
const username = m[1].toLowerCase();
const discrim = m[2].toLowerCase();
const user = users.cache
.sort((a, b) => a.username.length - b.username.length)
.filter((u) => u.username.toLowerCase() === username && u.discriminator === discrim)
.first();
if (user)
resolved.push(user);
}
else if (!strict)
{
const name = resolveable.toLowerCase();
const user = users.cache
.sort((a, b) => a.username.length - b.username.length)
.filter((u) => u.username.toLowerCase().includes(name))
.first();
if (user)
resolved.push(user);
}
}
return resolved;
}
async resolveUser (resolveable: string, strict?: boolean)
{
if (!resolveable)
return null;
const result = await this.resolveUsers([ resolveable ], strict);
return result.length ? result[0] : null;
}
/**
* Resolve multiple channels
*
* @param {Array<String>} [resolveables=[]] an array of channel resolveables (name, id)
* @param {Guild} guild the guild in which to look for channels
* @param {Boolean} [strict=false] whether or not partial names are resolved
* @param {Function} [filter=()] filter the resolving channels
* @returns {Promise<Array<GuildChannel>> || Promise<Boolean>} an array of guild channels or false if none were resolved
* @memberof Resolver
*/
async resolveChannels (
resolveables: string[] | string = [], strict = false, guild: Guild | null = null,
filter: (channel: Channel) => boolean = () => true
)
{
if (typeof resolveables === 'string')
resolveables = [ resolveables ];
if (resolveables.length === 0)
return [];
if (!guild)
guild = this.#client.mainServer;
const CM = guild.channels;
const resolved = [];
for (const resolveable of resolveables)
{
const channel = CM.resolve(resolveable);
if (channel && filter(channel))
{
resolved.push(channel);
continue;
}
const name = /^#?([a-z0-9\-_0]+)/iu;
const id = /^<?#?([0-9]{17,22})>?/iu;
if (id.test(resolveable))
{
const match = resolveable.match(id)!;
const [ , ch ] = match;
// eslint-disable-next-line @typescript-eslint/no-shadow
const channel = await this.#client.channels.fetch(ch).catch(() => null);
if (channel && filter(channel))
resolved.push(channel);
}
else if (name.test(resolveable))
{
const match = resolveable.match(name)!;
const ch = match[1].toLowerCase();
// eslint-disable-next-line @typescript-eslint/no-shadow
const channel = CM.cache.sort((a, b) => a.name.length - b.name.length).filter(filter).filter((c) =>
{
if (!strict)
return c.name.toLowerCase().includes(ch);
return c.name.toLowerCase() === ch;
}).first();
if (channel)
resolved.push(channel);
}
}
return resolved;
}
async resolveChannel (resolveable: string, strict?: boolean, guild?: Guild, filter?: (channel: Channel) => boolean)
{
if (!resolveable)
return null;
const result = await this.resolveChannels([ resolveable ], strict, guild, filter);
return result.length ? result[0] : null;
}
}
export default Resolver;

View File

@ -1,209 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import * as DiscordUtils from 'discord.js';
import moment from 'moment';
import { ModmailEntry } from '../@types/Modmail.js';
class Util
{
constructor ()
{
throw new Error('Class may not be instantiated.');
}
static isModmailHistory (history: unknown): history is ModmailEntry[]
{
if (!Array.isArray(history))
return false;
history.every(entry =>
{
const keys = Object.keys(entry);
for (const key of keys)
if (![ 'author', 'content', 'timestamp' ].includes(key))
return false;
return true;
});
return true;
}
static paginate<T = unknown> (items: T[], page = 1, pageLength = 10):
{ items: T[], page: number, maxPage: number, pageLength: number }
{
const maxPage = Math.ceil(items.length / pageLength);
if (page < 1)
page = 1;
if (page > maxPage)
page = maxPage;
const startIndex = (page - 1) * pageLength;
return {
items: items.length > pageLength ? items.slice(startIndex, startIndex + pageLength) : items,
page,
maxPage,
pageLength
};
}
static arrayIncludesAny (target: unknown[], compareTo: unknown[] = [])
{
if (!(compareTo instanceof Array))
compareTo = [ compareTo ];
for (const elem of compareTo)
{
if (target.includes(elem))
return true;
}
return false;
}
static downloadAsBuffer (source: string)
{
return new Promise((resolve, reject) =>
{
fetch(source).then((res) =>
{
if (res.ok)
resolve(res.arrayBuffer());
else
reject(res.statusText);
});
});
}
static readdirRecursive (directory: string)
{
const result = [];
// eslint-disable-next-line @typescript-eslint/no-shadow
(function read (directory: string)
{
const files = fs.readdirSync(directory);
for (const file of files)
{
const filePath = path.join(directory, file);
if (fs.statSync(filePath).isDirectory())
{
read(filePath);
}
else
{
result.push(filePath);
}
}
}(directory));
return result;
}
static wait (ms: number)
{
return this.delayFor(ms);
}
static delayFor (ms: number)
{
return new Promise((resolve) =>
{
setTimeout(resolve, ms);
});
}
static escapeMarkdown (text: string, options: DiscordUtils.EscapeMarkdownOptions)
{
if (typeof text !== 'string')
return text;
return DiscordUtils.escapeMarkdown(text, options);
}
static get formattingPatterns ()
{
return [
[ '\\*{1,3}([^*]*)\\*{1,3}', '$1' ],
[ '_{1,3}([^_]*)_{1,3}', '$1' ],
[ '`{1,3}([^`]*)`{1,3}', '$1' ],
[ '~~([^~])~~', '$1' ]
];
}
static removeMarkdown (content: string)
{
if (!content)
throw new Error('Missing content');
this.formattingPatterns.forEach(([ pattern, replacer ]) =>
{
content = content.replace(new RegExp(pattern, 'gu'), replacer);
});
return content.trim();
}
/**
* Sanitise user given regex; escapes unauthorised characters
*
* @static
* @param {string} input
* @param {string[]} [allowed=['?', '\\', '(', ')', '|']]
* @return {string} The sanitised expression
* @memberof Util
*/
static sanitiseRegex (input: string, allowed = [ '?', '\\', '(', ')', '|' ])
{
if (!input)
throw new Error('Missing input');
const reg = new RegExp(`[${this.regChars.filter((char) => !allowed.includes(char)).join('')}]`, 'gu');
return input.replace(reg, '\\$&');
}
static get regChars ()
{
return [ '.', '+', '*', '?', '\\[', '\\]', '^', '$', '(', ')', '{', '}', '|', '\\\\', '-' ];
}
static escapeRegex (string: string)
{
if (typeof string !== 'string')
{
throw new Error('Invalid type sent to escapeRegex.');
}
return string
.replace(/[|\\{}()[\]^$+*?.]/gu, '\\$&')
.replace(/-/gu, '\\x2d');
}
static duration (seconds: number)
{
const { plural } = this;
let s = 0,
m = 0,
h = 0,
d = 0,
w = 0;
s = Math.floor(seconds);
m = Math.floor(s / 60);
s %= 60;
h = Math.floor(m / 60);
m %= 60;
d = Math.floor(h / 24);
h %= 24;
w = Math.floor(d / 7);
d %= 7;
return `${w ? `${w} ${plural(w, 'week')} ` : ''}${d ? `${d} ${plural(d, 'day')} ` : ''}${h ? `${h} ${plural(h, 'hour')} ` : ''}${m ? `${m} ${plural(m, 'minute')} ` : ''}${s ? `${s} ${plural(s, 'second')} ` : ''}`.trim();
}
static plural (amt: number, word: string)
{
if (amt === 1)
return word;
return `${word}s`;
}
static get date ()
{
return moment().format('YYYY-MM-DD HH:mm:ss');
}
}
export default Util;

View File

@ -1,70 +0,0 @@
import { ModmailEntry } from '../../@types/Modmail.js';
import ModmailClient from '../Client.js';
abstract class Cache
{
#client: ModmailClient;
queue: string[];
channels: { [key: string]: string };
lastActivity: { [key: string]: number };
misc: { disabled?: boolean, lastReminder?: string, disabledReason?: string | null };
updatedThreads: string[];
constructor (client: ModmailClient)
{
this.#client = client;
this.queue = [];
this.updatedThreads = [];
this.channels = {};
this.lastActivity = {};
this.misc = {};
}
get client ()
{
return this.#client;
}
protected get logger ()
{
return this.#client.logger;
}
load ()
{
throw new Error('Not implemented');
}
savePersistentCache ()
{
throw new Error('Not implemented');
}
saveModmailHistory ()
{
throw new Error('Not implemented');
}
loadModmailHistory (_userId: string): Promise<ModmailEntry[]> | ModmailEntry[]
{
throw new Error('Not implemented');
}
verifyQueue ()
{
throw new Error('Not implemented');
}
get json ()
{
return {
queue: this.queue,
channels: this.channels,
lastActivity: this.lastActivity,
misc: this.misc
};
}
}
export default Cache;

View File

@ -1,105 +0,0 @@
import { CommandParams, CommandResponse, ErrorResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
import { TextBasedChannel } from 'discord.js';
class CannedReply extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'cannedreply',
aliases: [ 'cr', 'canned' ],
showUsage: true,
usage: '<canned response name>'
});
}
override async execute (message: ExtendedMessage<true>, { args }: CommandParams): Promise<CommandResponse>
{
const [ first, second ] = args.map((a) => a);
// eslint-disable-next-line prefer-const
let { content, _caller } = message,
anon = false,
channel = message.channel as TextBasedChannel;
content = content.replace(`${this.client.prefix}${_caller}`, '');
const op = args.shift()!.toLowerCase();
if (op === 'anon')
{
anon = true;
content = content.replace(first, '');
}
else if ([ 'create', 'delete' ].includes(op))
{
return this.createCanned(op, args, message);
}
else if ([ 'list' ].includes(first.toLowerCase()))
{
const list = Object.entries(this.client.modmail.replies);
let str = '';
if (second?.toLowerCase() !== '-here')
channel = await message.author.createDM();
// eslint-disable-next-line no-shadow
for (const [ name, msg ] of list)
{
const substr = `**${name}:** ${msg}\n`;
if (str.length + substr.length > 2000)
{
await channel.send(str);
str = '';
}
str += substr;
}
if (str.length)
return channel.send(str);
return '**__None__**';
}
return this.client.modmail.sendCannedResponse({ message, responseName: content.trim(), anon });
}
async createCanned (op: string, args: string[], { channel, author }: ExtendedMessage<true>): Promise<string | ErrorResponse>
{
if (args.length < 1)
return {
error: true,
msg: 'Missing reply name'
};
const [ _name, ...rest ] = args;
const name = _name.toLowerCase();
const canned = this.client.modmail.replies;
let confirmation = null;
if (op === 'create')
{
if (!rest.length)
return {
error: true,
msg: 'Missing content'
};
if (canned[name])
{
confirmation = await this.client.prompt(`A canned reply by the name ${name} already exists, would you like to overwrite it?`, { channel, author });
if (!confirmation)
return 'Timed out.';
confirmation = [ 'y', 'yes', 'ok' ].includes(confirmation.content.toLowerCase());
if (!confirmation)
return 'Cancelled';
}
canned[name] = rest.join(' ');
}
else
{
delete canned[name];
}
this.client.modmail.saveReplies();
return `Updated ${_name}`;
}
}
export default CannedReply;

View File

@ -1,35 +0,0 @@
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
import Util from '../Util.js';
class Ping extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'disable',
aliases: [ 'enable' ]
});
}
override async execute ({ author, member, _caller }: ExtendedMessage<true>, { clean }: CommandParams): Promise<CommandResponse>
{
const { sudo } = this.client;
if (!member)
throw new Error('Missing member');
const roleIds = member.roles.cache.map(r => r.id);
if (!Util.arrayIncludesAny(roleIds, sudo) && !sudo.includes(author.id))
return;
if (_caller === 'enable')
this.client.modmail.enable();
else
this.client.modmail.disable(clean);
return `:thumbsup: ${_caller}d`;
}
}
export default Ping;

View File

@ -1,62 +0,0 @@
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
import Util from '../Util.js';
import { inspect } from 'node:util';
import os from 'node:os';
const { username } = os.userInfo();
class Eval extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'eval',
aliases: [ 'e' ]
});
}
override async execute (message: ExtendedMessage<true>, { clean }: CommandParams): Promise<CommandResponse>
{
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const { guild, author, member, client, channel } = message;
const { sudo } = this.client;
if (!member)
throw new Error('Missing member');
const roleIds = member.roles.cache.map(r => r.id);
if (!Util.arrayIncludesAny(roleIds, sudo) && !sudo.includes(author.id))
return;
try
{
let evaled = eval(clean); // eslint-disable-line no-eval
if (evaled instanceof Promise)
evaled = await evaled;
if (typeof evaled !== 'string')
evaled = inspect(evaled);
evaled = evaled
.replace(new RegExp(this.client.token!, 'gu'), '<redacted>')
.replace(new RegExp(username, 'gu'), '<redacted>');
if (evaled.length > 1850)
evaled = `${evaled.substring(0, 1850)}...`;
await channel.send(`Evaluation was successful.\`\`\`js\n${evaled}\`\`\``);
}
catch (err)
{
const error = err as Error;
let msg = `${error}${error.stack ? `\n${error.stack}` : ''}`;
// if (args.log) guild._debugLog(`[${message.author.tag}] Evaluation Fail: ${msg}`);
if (msg.length > 2000)
msg = `${msg.substring(0, 1900)}...`;
await channel.send(`Evaluation failed.\`\`\`js\n${msg}\`\`\``);
}
}
}
export default Eval;

View File

@ -1,39 +0,0 @@
import { ChannelType } from 'discord.js';
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
class ModmailID extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'id',
aliases: [ 'mmid' ]
});
}
override async execute (message: ExtendedMessage<true>, { args }: CommandParams): Promise<CommandResponse>
{
let channel = null;
if (args?.length)
channel = await this.client.resolveChannel(args[0]);
else
({ channel } = message);
if (!channel || channel.type !== ChannelType.GuildText)
return {
error: true,
msg: 'Invalid channel'
};
const result = this.client.getUserFromChannel(channel);
if ('error' in result && result.error)
return result;
const [ userId ] = result as [string, string];
return userId;
}
}
export default ModmailID;

View File

@ -1,85 +0,0 @@
import { APIEmbed } from 'discord.js';
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
import Util from '../Util.js';
class Logs extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'logs',
aliases: [ 'mmlogs', 'mmhistory', 'mmlog' ],
showUsage: true,
usage: '<user> [page]'
});
}
override async execute (message: ExtendedMessage<true>, { args }: CommandParams): Promise<CommandResponse>
{
const user = await this.client.resolveUser(args[0]);
if (!user)
return {
error: true,
msg: 'Invalid user'
};
let pageNr = 1;
if (args[1])
{
const num = parseInt(args[1]);
if (isNaN(num))
return {
error: true,
msg: 'Invalid page number, must be number'
};
pageNr = num;
}
const { member, channel } = message;
if (!member)
throw new Error('Missing member');
const history = await this.client.cache.loadModmailHistory(user.id);
if (!history.length)
return 'Not found in modmail DB';
const page = Util.paginate([ ...history ].filter((e) => !('readState' in e)).reverse(), pageNr, 10);
const embed: APIEmbed = {
author: {
name: `${user.tag} modmail history`,
// eslint-disable-next-line camelcase
icon_url: user.displayAvatarURL()
},
footer: {
text: `${user.id} | Page ${page.page}/${page.maxPage}`
},
fields: [],
color: member.roles.highest.color
};
for (const entry of page.items)
{
// eslint-disable-next-line @typescript-eslint/no-shadow
const user = await this.client.resolveUser(entry.author);
if (!entry.content || !user)
continue;
let value = entry.content.substring(0, 1000) + (entry.content.length > 1000 ? '...' : '');
if (!value.length)
value = entry.attachments?.join('\n') ?? '';
embed.fields!.push({
name: `${user.tag}${entry.anon ? ' (ANON)' : ''} @ ${new Date(entry.timestamp).toUTCString()}`,
value
});
if (entry.attachments?.length && entry.content.length)
embed.fields!.push({
name: 'Attachments',
value: entry.attachments.join('\n')
});
}
await channel.send({ embeds: [ embed ] });
}
}
export default Logs;

View File

@ -1,21 +0,0 @@
import ModmailClient from '../Client.js';
import Command from '../Command.js';
import { CommandParams, ExtendedMessage } from '../../@types/Client.js';
class Markread extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'markread'
});
}
override async execute (message: ExtendedMessage<true>, { args }: CommandParams)
{
return this.client.modmail.changeReadState(message, args);
}
}
export default Markread;

View File

@ -1,21 +0,0 @@
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
class Markunread extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'markunread'
});
}
override async execute (message: ExtendedMessage<true>, { args }: CommandParams): Promise<CommandResponse>
{
return this.client.modmail.changeReadState(message, args, 'unread');
}
}
export default Markunread;

View File

@ -1,68 +0,0 @@
import { AttachmentBuilder, ChannelType } from 'discord.js';
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
class MessageIds extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'messageids',
aliases: [ 'msgids', 'mmids' ]
});
}
override async execute (message: ExtendedMessage, { args }: CommandParams): Promise<CommandResponse>
{
let channel = null;
if (args?.length)
channel = await this.client.resolveChannel(args[0]);
else
({ channel } = message);
if (!channel || channel.type !== ChannelType.GuildText)
return { error: true, msg: 'Invalid channel' };
const result = this.client.getUserFromChannel(channel);
if ('error' in result && result.error)
return result;
const [ userId ] = result as [string, string];
const user = await this.client.users.fetch(userId);
const dmChannel = await user.createDM();
const history = await this.client.cache.loadModmailHistory(userId);
const sorted = history.sort((a, b) => b.timestamp - a.timestamp);
const idContentPairs = [];
const urlBase = 'https://discord.com/channels/@me';
for (const mm of sorted)
{
if (!mm.msgId && !mm.isReply)
break; // Old modmails from before msg id logging -- could probably supplement with fetching messages but cba rn
idContentPairs.push(`${urlBase}/${dmChannel.id}/${mm.msgId} - ${mm.content}`);
}
if (!idContentPairs.length)
{
const msgs = await dmChannel.messages.fetch();
const sortedMsgs = msgs
.filter(msg => msg.author.id !== this.client.user!.id)
.sort((a, b) => b.createdTimestamp - a.createdTimestamp);
for (const msg of sortedMsgs.values())
idContentPairs.push(`${urlBase}/${dmChannel.id}/${msg.id} - ${msg.content}`);
}
await message.channel.send({
files: [
new AttachmentBuilder(
Buffer.from(`ID - Content pairs for\n${user.tag} - ${user.id}\n\n${idContentPairs.join('\n')}`),
{ name: 'ids.txt' }
)
]
});
}
}
export default MessageIds;

View File

@ -1,61 +0,0 @@
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
class Modmail extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'modmail',
aliases: [ 'mm' ],
showUsage: true,
usage: '<user> <content>'
});
}
override async execute (message: ExtendedMessage, { args }: CommandParams): Promise<CommandResponse>
{
// eslint-disable-next-line prefer-const
let [ first, second ] = args.map((a) => a);
// eslint-disable-next-line prefer-const
let { content, _caller } = message,
anon = false;
content = content.replace(`${this.client.prefix}${_caller}`, '');
if (first.toLowerCase() === 'anon')
{
anon = true;
content = content.replace(first, '');
first = second;
}
else if (second?.toLowerCase() === 'anon')
{
anon = true;
content = content.replace(second, '');
}
const user = await this.client.resolveUser(first, true);
if (!user)
return {
error: true,
msg: 'Failed to resolve user'
};
else if (user.bot)
return {
error: true,
msg: 'Cannot send modmail to a bot.'
};
content = content.replace(first, '').trim();
if (!content.length)
return {
error: true,
msg: 'Cannot send empty message'
};
return this.client.modmail.sendModmail({ message, content, anon, target: user });
}
}
export default Modmail;

View File

@ -1,20 +0,0 @@
import ModmailClient from '../Client.js';
import Command from '../Command.js';
class Ping extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'ping'
});
}
override execute ()
{
return 'PONG!';
}
}
export default Ping;

View File

@ -1,34 +0,0 @@
import { CommandParams, CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
class Reply extends Command
{
constructor (client: ModmailClient)
{
super(client, {
name: 'reply',
aliases: [ 'r' ],
showUsage: true,
usage: '<reply content>'
});
}
override async execute (message: ExtendedMessage<true>, { args }: CommandParams): Promise<CommandResponse>
{
const [ first ] = args.map((a) => a);
// eslint-disable-next-line prefer-const
let { content, _caller } = message,
anon = false;
content = content.replace(`${this.client.prefix}${_caller}`, '');
if (first.toLowerCase() === 'anon')
{
anon = true;
content = content.replace(first, '');
}
return this.client.modmail.sendResponse({ message, content: content.trim(), anon });
}
}
export default Reply;

312
structure/ChannelHandler.js Normal file
View File

@ -0,0 +1,312 @@
class ChannelHandler {
constructor (modmail, opts) {
this.modmail = modmail;
this.client = modmail.client;
this.awaitingChannel = {};
this.mainServer = null;
this.bansServer = null;
this.categories = opts.modmailCategory.map((id) => id.toString());
this.cache = modmail.cache;
this.graveyardInactive = opts.graveyardInactive;
this.readInactive = opts.readInactive;
this.channelSweepInterval = opts.channelSweepInterval;
}
init () {
this.mainServer = this.modmail.mainServer;
this.bansServer = this.modmail.bansServer;
const { channels } = this.mainServer;
this.newMail = channels.resolve(this.categories[0]);
if (!this.newMail) this.client.logger.warn(`Missing new mail category!`);
this.readMail = channels.resolve(this.categories[1]);
if (!this.readMail) this.client.logger.warn(`Missing read mail category!`);
this.graveyard = channels.resolve(this.categories[2]);
if (!this.graveyard) this.client.logger.warn(`Missing graveyard category!`);
if (!this.newMail || !this.readMail || !this.graveyard) {
this.client.logger.debug(`Some mail categories were missing: ${this.categories}`);
}
// Sweep graveyard every x min and move stale channels to graveyard
this.sweeper = setInterval(this.sweepChannels.bind(this), this.channelSweepInterval * 60 * 1000);
}
async send (target, embed, newEntry) {
// Load & update the users past modmails
const history = await this.cache.loadModmailHistory(target.id)
.catch((err) => {
this.client.logger.error(`Error during loading of past mail:\n${err.stack}`);
return { error: true };
});
if (history.error) return {
error: true,
msg: `Internal error, this has been logged.`
};
const channel = await this.load(target, history).catch(this.client.logger.error.bind(this.client.logger));
history.push(newEntry);
const sent = await channel.send({ embed }).catch((err) => {
this.client.logger.error(`channel.send errored:\n${err.stack}\nContent: "${embed}"`);
});
await channel.edit({ parentID: this.readMail.id, lockPermissions: true }).catch((err) => {
this.client.logger.error(`Error during channel transition:\n${err.stack}`);
});
if (this.cache.queue.includes(target.id)) this.cache.queue.splice(this.cache.queue.indexOf(target.id), 1);
if (!this.cache.updatedThreads.includes(target.id)) this.cache.updatedThreads.push(target.id);
if (this.readMail.children.size > 45) this.sweepChannels({ count: 5, force: true });
return sent;
}
async setReadState (target, channel, staff, state) {
const history = await this.cache.loadModmailHistory(target)
.catch((err) => {
this.client.logger.error(`Error during loading of past mail:\n${err.stack}`);
return { error: true };
});
if (history.error) return {
error: true,
msg: `Internal error, this has been logged.`
};
if (!history.length) return {
error: true,
msg: `User has no modmail history.`
};
if (history[history.length - 1].readState !== state) history.push({ author: staff.id, timestamp: Date.now(), readState: state }); // To keep track of read state
if (state === 'unread') await this.load(await this.client.resolveUser(target), history);
if (channel) await channel.edit({ parentID: state === 'read' ? this.readMail.id : this.newMail.id, lockPermissions: true });
if (!this.cache.updatedThreads.includes(target)) this.cache.updatedThreads.push(target);
if (this.cache.queue.includes(target) && state === 'read') this.cache.queue.splice(this.cache.queue.indexOf(target), 1);
else if (!this.cache.queue.includes(target) && state === 'unread') this.cache.queue.push(target);
return {};
}
/**
* Process channels for incoming modmail
*
* @param {GuildMember} member
* @param {string} id
* @param {Array<object>} history
* @return {TextChannel}
* @memberof ChannelHandler
*/
load (target, history) {
if (this.awaitingChannel[target.id]) return this.awaitingChannel[target.id];
// eslint-disable-next-line no-async-promise-executor
const promise = new Promise(async (resolve, reject) => {
const channelID = this.modmail.cache.channels[target.id];
const guild = this.mainServer;
const user = target.user || target;
const member = target.user ? target : null;
let channel = guild.channels.resolve(channelID);
const { context } = this.client._options;
if (this.newMail.children.size >= 45) this.overflow();
if (!channel) { // Create and populate channel
channel = await guild.channels.create(`${user.username}_${user.discriminator}`, {
parent: this.newMail.id
}).catch(err => {
this.client.logger.warn(`Failed to create channel for ${user.tag}, trying again.\n${err.stack || err}`);
});
if (!channel) channel = await guild.channels.create(`${user.id}`, {
parent: this.newMail.id
}).catch(err => {
// this.client.logger.error(`Failed on second create:\n${err.stack || err}`);
return { err };
});
if (channel.err) return reject(channel.err);
// Start with user info embed
const embed = {
author: { name: user.tag },
thumbnail: {
url: user.displayAvatarURL({ dynamic: true })
},
fields: [
{
name: '__User Data__',
value: `**User:** <@${user.id}>\n`
+ `**Account created:** ${user.createdAt.toDateString()}\n`,
inline: false
}
],
footer: { text: `• User ID: ${user.id}` },
color: guild.me.highestRoleColor
};
if (member && member.inAppealServer) {
if (guild.me.hasPermission('BAN_MEMBERS')) {
const ban = await guild.fetchBan(member.id).catch(() => null);
if (ban) embed.description = `**__USER IS BANNED FROM MAIN SERVER__**`;
else embed.description = `**__USER IS IN APPEAL SERVER BUT NOT BANNED FROM MAIN__**`;
} else embed.description = `**__USER IS IN APPEAL SERVER__**`;
} else if (member) embed.fields.push({
name: '__Member Data__',
value: `**Nickname:** ${member.nickname || 'N/A'}\n`
+ `**Server join date:** ${member.joinedAt.toDateString()}\n`
+ `**Roles:** ${member.roles.cache.filter((r) => r.id !== guild.roles.everyone.id).map((r) => `<@&${r.id}>`).join(' ')}`,
inline: false
});
await channel.send({ embed }).catch(err => this.client.logger.error(`ChannelHandler.load errored at channel.send:\n${err.stack}`));
// Load in context
const len = history.length;
for (let i = context < len ? context : len; i > 0; i--) {
const entry = history[len - i];
if (!entry) continue;
if ([ 'read', 'unread' ].includes(entry.readState)) continue;
// eslint-disable-next-line no-shadow
const user = await this.client.resolveUser(entry.author).catch(this.client.logger.error.bind(this.client.logger));
const mem = await this.modmail.getMember(user.id).catch(this.client.logger.error.bind(this.client.logger));
if (!user) return reject(new Error(`Failed to find user`));
// eslint-disable-next-line no-shadow
const embed = {
footer: {
text: user.id
},
author: {
// eslint-disable-next-line no-nested-ternary
name: user.tag + (entry.anon ? ' (ANON)' : entry.isReply ? ' (STAFF)' : ''),
// eslint-disable-next-line camelcase
icon_url: user.displayAvatarURL({ dynamic: true })
},
// eslint-disable-next-line no-nested-ternary
description: entry.content && entry.content.length ? entry.content.length > 2000 ? `${entry.content.substring(0, 2000)}...\n\n**Content cut off**` : entry.content : `**__MISSING CONTENT__**`,
color: mem?.highestRoleColor || 0,
fields: [],
timestamp: new Date(entry.timestamp)
};
if (entry.attachments && entry.attachments.length) embed.fields.push({
name: '__Attachments__',
value: entry.attachments.join('\n').substring(0, 1000)
});
await channel.send({ embed }).catch(err => this.client.logger.error(`ChannelHandler.load errored at channel.send:\n${err.stack}`));
}
this.modmail.cache.channels[user.id] = channel.id;
}
// Ensure the right category
// if (channel.parentID !== this.newMail.id)
await channel.edit({ parentID: this.newMail.id, lockPermissions: true }).catch((err) => {
this.client.logger.error(`Error during channel transition:\n${err.stack}`);
});
delete this.awaitingChannel[user.id];
resolve(channel);
});
this.awaitingChannel[target.id] = promise;
return promise;
}
/**
*
*
* @param {object} { age, count, force } age: how long the channel has to be without activity to be deleted, count: how many channels to act on, force: whether to ignore answered status
* @memberof Modmail
*/
async sweepChannels ({ age, count, force = false } = {}) {
this.client.logger.info(`Sweeping graveyard`);
const now = Date.now();
if (!this.graveyard) return this.client.logger.error(`Missing graveyard category!`);
const graveyardChannels = this.graveyard.children.sort((a, b) => {
if (!a.lastMessage) return -1;
if (!b.lastMessage) return 1;
return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp;
}).array();
let channelCount = 0;
if (!age) age = this.graveyardInactive * 60 * 1000;
for (const channel of graveyardChannels) {
const { lastMessage } = channel;
if (!lastMessage || now - lastMessage.createdTimestamp > age || count && channelCount <= count) {
await channel.delete().then((ch) => {
const chCache = this.cache.channels;
const _cached = Object.entries(chCache).find(([ , val ]) => {
return val === ch.id;
});
if (_cached) {
const [ userId ] = _cached;
delete this.modmail.cache.channels[userId];
}
}).catch((err) => {
this.client.logger.error(`Failed to delete channel ${channel?.id || channel} from graveyard during sweep:\n${err.stack}`);
});
channelCount++;
}
}
this.client.logger.info(`Swept ${channelCount} channels from graveyard, cleaning up answered...`);
if (!this.readMail) return this.client.logger.error(`Missing read mail category!`);
const answered = this.readMail.children
.filter((channel) => !channel.lastMessage || channel.lastMessage.createdTimestamp < Date.now() - this.readInactive * 60 * 1000 || force)
.sort((a, b) => {
if (!a.lastMessage) return -1;
if (!b.lastMessage) return 1;
return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp;
}).array();
let chCount = this.graveyard.children.size;
for (const ch of answered) {
if (chCount < 50) {
await ch.edit({ parentID: this.graveyard.id, lockPermissions: true }).catch((err) => {
this.client.logger.error(`Failed to move channel to graveyard during sweep:\n${err.stack}`);
});
chCount++;
} else break;
}
this.client.logger.info(`Sweep done. Took ${Date.now() - now}ms`);
}
async overflow () { // Overflows new modmail category into read
const channels = this.newMail.children.sort((a, b) => {
if (!a.lastMessage) return -1;
if (!b.lastMessage) return 1;
return a.lastMessage.createdTimestamp - b.lastMessage.createdTimestamp;
}).array();
if (this.readMail.children.size >= 45) await this.sweepChannels({ count: 5, force: true });
let counter = 0;
for (const channel of channels) {
await channel.edit({ parentID: this.readMail.id, lockPermissions: true });
counter++;
if (counter === 5) break;
}
}
}
module.exports = ChannelHandler;

186
structure/Client.js Normal file
View File

@ -0,0 +1,186 @@
const { inspect } = require('util');
const { Client } = require('discord.js');
// eslint-disable-next-line no-unused-vars
const { TextChannel, GuildMember } = require('./extensions');
const { Logger } = require('../logger');
const Modmail = require('./Modmail');
const Registry = require('./Registry');
const Resolver = require('./Resolver');
const Cache = require('./JsonCache');
class ModmailClient extends Client {
constructor (options) {
super(options.clientOptions);
this._options = options;
this._ready = false;
this.prefix = options.prefix;
this.logger = new Logger(this, options.loggerOptions);
this.registry = new Registry(this);
this.resolver = new Resolver(this);
this.cache = new Cache(this);
this.modmail = new Modmail(this);
this.on('ready', () => {
this.logger.info(`Client ready, logged in as ${this.user.tag}`);
});
}
async init () {
this.registry.loadCommands();
this.on('message', this.handleMessage.bind(this));
this.cache.load();
this.logger.info(`Logging in`);
const promise = this.ready();
await this.login(this._options.discordToken);
await promise;
this.mainServer = this.guilds.cache.get(this._options.mainGuild);
this.bansServer = this.guilds.cache.get(this._options.bansGuild) || null;
this.logger.info(`Starting up modmail handler`);
await this.modmail.init();
process.on('exit', () => {
this.logger.warn('process exiting');
this.cache.savePersistentCache();
this.cache.saveModmailHistory(this.modmail);
});
process.on('SIGINT', () => {
this.logger.warn('received sigint');
// this.cache.save();
// this.cache.saveModmailHistory(this.modmail);
// eslint-disable-next-line no-process-exit
process.exit();
});
process.on('unhandledRejection', (reason, prom) => {
this.logger.error(`Unhandled promise rejection at: ${inspect(prom)}\nReason: ${reason}`);
});
this._ready = true;
await this.modmail.reminderChannel.send(`Modmail bot booted and ready.`);
}
ready () {
return new Promise((resolve) => {
if (this._ready) return resolve();
this.once('ready', resolve);
});
}
async handleMessage (message) {
if (!this._ready) return;
if (message.author.bot) return;
// No command handling in dms, at least for now
if (!message.guild) try {
return this.modmail.handleUser(message);
} catch (err) {
this.logger.error(`Error during user handle:\n${err.stack}`);
return;
}
const { prefix } = this;
const { channel, guild, content, member } = message;
if (![ this.mainServer.id, this.bansServer?.id || '0' ].includes(guild.id)) return;
if (!content || !content.startsWith(prefix)) return;
const roles = member.roles.cache.map((r) => r.id);
if (!roles.some((r) => this._options.staffRoles.includes(r)) && !member.hasPermission('ADMINISTRATOR')) return;
const [ rawCommand, ...args ] = content.split(/\s+/u);
const commandName = rawCommand.substring(prefix.length);
const command = this.registry.find(commandName);
if (!command) return;
message._caller = commandName;
if (command.showUsage && !args.length) {
let helpStr = `**${command.name}**\nUsage: ${this.prefix}${command.name} ${command.usage}`;
if (command.aliases) helpStr += `\nAliases: ${command.aliases.join(', ')}`;
return channel.send(helpStr).catch(err => this.logger.error(`Client.handleMessage errored at channel.send:\n${err.stack}`));
}
this.logger.debug(`${message.author.tag} is executing command ${command.name}`);
const clean = message.content.replace(`${this.prefix}${commandName}`, '').trim();
const result = await command.execute(message, { args: [ ...args ], clean }).catch((err) => {
this.logger.error(`Command ${command.name} errored during execution:\nARGS: [ "${args.join('", "')}" ]\n${err.stack}`);
return {
error: true,
msg: `Command ${command.name} ran into an error during execution. This has been logged.`
};
});
if (!result) return;
if (result.error) return channel.send(result.msg).catch(err => this.logger.error(`Client.load errored at channel.send:\n${err.stack}\n${inspect(result)}`));
else if (result.response) return channel.send(result.response).catch(err => this.logger.error(`Client.load errored at channel.send:\n${err.stack}\n${inspect(result)}`));
else if (typeof result === 'string') return channel.send(result).catch(err => this.logger.error(`Client.load errored at channel.send:\n${err.stack}\n${inspect(result)}`));
}
resolveUser (...args) {
return this.resolver.resolveUser(...args);
}
resolveUsers (...args) {
return this.resolver.resolveUsers(...args);
}
resolveChannels (...args) {
return this.resolver.resolveChannels(...args);
}
resolveChannel (...args) {
return this.resolver.resolveChannel(...args);
}
async prompt (str, { author, channel, time }) {
if (!channel && author) channel = await author.createDM();
if (!channel) throw new Error(`Missing channel for prompt, must pass at least author.`);
await channel.send(str).catch(err => this.logger.error(`Client.prompt errored at channel.send:\n${err.stack}`));
return channel.awaitMessages((m) => m.author.id === author.id, { max: 1, time: time || 30000, errors: [ 'time' ] })
.then((collected) => {
return collected.first();
})
.catch((error) => { // eslint-disable-line no-unused-vars, handle-callback-err
return null;
});
}
getUserFromChannel (channel) {
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) => {
return val === channel.id;
});
if (!result) return {
error: true,
msg: `This doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**`
};
return result;
}
}
module.exports = ModmailClient;

21
structure/Command.js Normal file
View File

@ -0,0 +1,21 @@
class Command {
constructor (client, options) {
Object.entries(options).forEach(([ key, val ]) => {
this[key] = val;
});
if (!this.name) throw new Error(`Missing name for command`);
this.client = client;
}
async execute () {
throw new Error(`Missing execute in ${this.name}`);
}
}
module.exports = Command;

152
structure/JsonCache.js Normal file
View File

@ -0,0 +1,152 @@
const fs = require('fs');
const pathUtil = require('path');
const CacheHandler = require('./abstractions/CacheHandler');
class JsonCache extends CacheHandler {
constructor (client) {
super(client);
const opts = client._options;
this.logger = client.logger;
this.saveInterval = opts.saveInterval;
this._ready = false;
// Data that gets stored to persistent cache
this.queue = [];
this.channels = {};
this.lastActivity = {};
this.misc = {}; // Random misc data, should not be non-primitive data types
// Stored separately if at all
this.modmail = {};
this.updatedThreads = [];
}
load () {
if (this._ready) return;
if (fs.existsSync('./persistent_cache.json')) {
this.logger.info('Loading cache');
const raw = JSON.parse(fs.readFileSync('./persistent_cache.json', { encoding: 'utf-8' }));
const entries = Object.entries(raw);
for (const [ key, val ] of entries) this[key] = val;
} else {
this.logger.info('Cache file missing, creating...');
this.savePersistentCache();
}
this.cacheSaveInterval = setInterval(this.savePersistentCache.bind(this), 10 * 60 * 1000);
this.modmailSaveInterval = setInterval(this.saveModmailHistory.bind(this), this.saveInterval * 60 * 1000, this.client.modmail);
this._ready = true;
}
savePersistentCache () {
this.logger.debug('Saving cache');
fs.writeFileSync('./persistent_cache.json', JSON.stringify(this.json, null, 4));
}
saveModmailHistory () {
if (!this.updatedThreads.length) return;
const toSave = [ ...this.updatedThreads ];
this.updatedThreads = [];
this.client.logger.debug(`Saving modmail data`);
if (!fs.existsSync('./modmail_cache')) fs.mkdirSync('./modmail_cache');
for (const id of toSave) {
const path = `./modmail_cache/${id}.json`;
try {
fs.writeFileSync(path, JSON.stringify(this.modmail[id], null, 4));
} catch (err) {
this.client.logger.error(`Error during saving of history\n${id}\n${JSON.stringify(this.modmail)}\n${err.stack}`);
}
}
}
loadModmailHistory (userId) {
return new Promise((resolve, reject) => {
if (this.modmail[userId]) return resolve(this.modmail[userId]);
const path = `./modmail_cache/${userId}.json`;
if (!fs.existsSync(path)) {
this.modmail[userId] = [];
return resolve(this.modmail[userId]);
}
fs.readFile(path, { encoding: 'utf-8' }, (err, data) => {
if (err) reject(err);
const parsed = JSON.parse(data);
this.modmail[userId] = parsed;
resolve(parsed);
});
});
}
async verifyQueue () {
this.client.logger.info(`Verifying modmail queue.`);
for (const entry of this.queue) {
const path = `./modmail_cache/${entry}.json`;
if (!fs.existsSync(pathUtil.resolve(path))) this.client.logger.warn(`User ${entry} is in queue but is missing history. Attempting to recover history.`);
const user = await this.client.resolveUser(entry);
const dm = await user.createDM();
let messages = await dm.messages.fetch();
messages = messages.sort((a, b) => a.createdTimestamp - b.createdTimestamp).map(msg => msg); // .filter(msg => msg.author.id !== this.client.user.id)
const amt = messages.length;
const history = await this.loadModmailHistory(entry);
if (history.length) { // Sync user's past messages with the bot's cache if one exists
const last = history[history.length - 1];
let index = amt - 1;
for (index; index >= 0; index--) { // Find the most recent message that is also in the user's history
const msg = messages[index];
if (msg.content === last.content || msg.embeds.length && msg.author.bot) {
messages = messages.slice(index+1).filter(m => !m.author.bot);
break;
}
}
if (messages.length) this.client.logger.warn(`User ${entry} has previous history but is out of sync, attempting sync. ${messages.length} messages out of sync.`);
else continue;
}
history.push({ timestamp: Date.now(), author: this.client.user.id, content: 'Attempted a recovery of missing messages at this point, messages may look out of place if something went wrong.' });
for (const { author, content, createdTimestamp, attachments } of messages) {
if (author.bot) continue;
history.push({ attachments: attachments.map(att => att.url), author: author.id, content, timestamp: createdTimestamp });
}
this.updatedThreads.push(entry);
}
this.client.logger.info(`Queue verified.`);
this.saveModmailHistory();
}
get json () {
return {
queue: this.queue,
channels: this.channels,
lastActivity: this.lastActivity,
misc: this.misc
};
}
}
module.exports = JsonCache;

455
structure/Modmail.js Normal file
View File

@ -0,0 +1,455 @@
const fs = require('fs');
const ChannelHandler = require('./ChannelHandler');
class Modmail {
// A lot of this can probably be simplified but I wrote all of this in 2 days and I cba to fix this atm
// TODO: Fix everything
constructor (client) {
this.client = client;
this.cache = client.cache;
this.mainServer = null;
this.bansServer = null;
this.logChannel = null;
this.reminderChannel = null;
const opts = client._options;
this.anonColor = opts.anonColor;
this.reminderInterval = opts.modmailReminderInterval || 30;
this._reminderChannel = opts.modmailReminderChannel || null;
this._logChannel = opts.logChannel || null;
this.categories = opts.modmailCategory;
this.inlineResponse = opts.inlineResponse || `Thank you for your message, we'll get back to you soon!`;
this.updatedThreads = [];
this.queue = [];
this.spammers = {};
this.replies = {};
this.lastReminder = null;
this.disabled = false;
this.disabledReason = null;
this.channels = new ChannelHandler(this, opts);
this._ready = false;
}
async init () {
this.mainServer = this.client.mainServer;
if (!this.mainServer) throw new Error(`Missing main server`);
this.bansServer = this.client.bansServer;
if (!this.bansServer) this.client.logger.warn(`Missing bans server`);
if (!this.anonColor) this.anonColor = this.mainServer.me.highestRoleColor;
this.replies = this.loadReplies();
this.queue = this.client.cache.queue;
if (this._reminderChannel) {
this.reminderChannel = this.client.channels.resolve(this._reminderChannel);
this.reminder = setInterval(this.sendReminder.bind(this), this.reminderInterval * 60 * 1000);
this.lastReminder = await this.reminderChannel.messages.fetch(this.cache.misc.lastReminder).catch(() => null);
this.sendReminder();
}
if (this._logChannel) {
this.logChannel = this.client.channels.resolve(this._logChannel);
}
let logStr = `Started modmail handler for ${this.mainServer.name}`;
if (this.bansServer) logStr += ` with ${this.bansServer.name} for ban appeals`;
this.client.logger.info(logStr);
// this.client.logger.info(`Fetching messages from discord for modmail`);
// TODO: Fetch messages from discord in modmail channels
this.disabled = this.cache.misc.disabled || false;
this.disabledReason = this.cache.misc.disabledReason || null;
this.channels.init();
this._ready = true;
}
async getMember (user) {
let result = this.mainServer.members.cache.get(user);
if (!result) result = await this.mainServer.members.fetch(user).catch(() => {
return null;
});
if (!result && this.bansServer) {
result = this.bansServer.members.cache.get(user);
if (!result) result = await this.bansServer.members.fetch(user).catch(() => {
return null;
});
if (result) result.inAppealServer = true;
}
return result;
}
async getUser (user) {
let result = this.client.users.cache.get(user);
if (!result) result = await this.client.users.fetch(user).catch(() => {
return null;
});
return result;
}
async handleUser (message) {
const { author, content } = message;
const member = await this.getMember(author.id);
if (!member) return; // No member object found in main or bans server?
const now = Math.floor(Date.now() / 1000);
const { cache } = this;
// Anti spam -- never seen user
if (!this.spammers[author.id]) this.spammers[author.id] = {
start: now, // when counting started
count: 1, // # messages
timeout: false, // timed out?
warned: false // warned?
};
else if (this.spammers[author.id].timeout) { // User was timed out, check if 5 minutes have passsed, if so, reset their timeout else ignore them
if (now - this.spammers[author.id].start > 5 * 60) this.spammers[author.id] = { start: now, count: 1, timeout: false, warned: false };
else return;
} else if (this.spammers[author.id].count > 5 && now - this.spammers[author.id].start < 15) {
// Has sent more than 5 messages in less than 15 seconds at this point, time them out
this.spammers[author.id].timeout = true;
if (!this.spammers[author.id].warned) { // Let them know they've been timed out, toggle the warned property so it doesn't send the warning every time
this.spammers[author.id].warned = true;
await author.send(`I've blocked you for spamming, please try again in 5 minutes`);
if (cache._channels[author.id]) await cache._channels[author.id].send(`I've blocked ${author.tag} from DMing me as they were spamming.`);
}
} else if (now - this.spammers[author.id].start > 15) this.spammers[author.id] = { start: now, count: 1, timeout: false, warned: false }; // Enough time has passed, reset the object
else this.spammers[author.id].count++;
if (this.disabled) {
let reason = `Modmail has been disabled for the time being`;
if (this.disabledReason) reason += ` for the following reason:\n\n${this.disabledReason}`;
else reason += `.`;
return author.send(reason);
}
const lastActivity = this.cache.lastActivity[author.id];
if (!lastActivity || now - lastActivity > 30 * 60) { // No point in sending this for *every* message
await author.send(this.inlineResponse);
}
this.cache.lastActivity[author.id] = now;
const pastModmail = await this.cache.loadModmailHistory(author.id)
.catch((err) => {
this.client.logger.error(`Error during loading of past mail:\n${err.stack}`);
return { error: true };
});
if (pastModmail.error) return author.send(`Internal error, this has been logged.`);
const channel = await this.channels.load(member, pastModmail)
.catch((err) => {
this.client.logger.error(`Error during channel handling:\n${err.stack || err}`);
return { error: true };
});
if (channel.error) return author.send(`Internal error, this has been logged.`);
if (!cache._channels) cache._channels = {};
cache._channels[author.id] = channel;
const embed = {
footer: {
text: member.id
},
author: {
name: member.user.tag,
// eslint-disable-next-line camelcase
icon_url: member.user.displayAvatarURL({ dynamic: true })
},
// eslint-disable-next-line no-nested-ternary
description: content && content.length ? content.length > 2000 ? `${content.substring(0, 2000)}...\n\n**Content cut off**` : content : `**__MISSING CONTENT__**`,
color: member.highestRoleColor,
fields: [],
timestamp: new Date()
};
const attachments = message.attachments.map((att) => att.url);
if (message.attachments.size) {
embed.fields.push({
name: '__Attachments__',
value: attachments.join('\n').substring(0, 1000)
});
}
pastModmail.push({ attachments, author: author.id, content, timestamp: Date.now(), isReply: false, msgId: message.id });
if (!this.updatedThreads.includes(author.id)) this.updatedThreads.push(author.id);
if (!this.queue.includes(author.id)) this.queue.push(author.id);
this.log({ author, action: `${author.tag} (${author.id}) sent new modmail`, content });
await channel.send({ embed }).catch((err) => {
this.client.logger.error(`channel.send errored:\n${err.stack}\nContent: "${content}"`);
});
}
async sendCannedResponse ({ message, responseName, anon }) {
const content = this.getCanned(responseName);
if (!content) return {
error: true,
msg: `No canned reply by the name \`${responseName}\` exists`
};
return this.sendResponse({ message, content, anon });
}
// Send reply from channel
async sendResponse ({ message, content, anon }) {
const { channel, member, author } = message;
if (!this.categories.includes(channel.parentID)) return {
error: true,
msg: `This command only works in modmail channels.`
};
// Resolve target user from cache
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) => {
return val === channel.id;
});
if (!result) return {
error: true,
msg: `This doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**`
};
// Ensure target exists, this should never run into issues
const [ userId ] = result;
const targetMember = await this.getMember(userId);
if (!targetMember) return {
error: true,
msg: `User seems to have left.\nReport this if the user is still present.`
};
this.log({ author, action: `${author.tag} replied to ${targetMember.user.tag}`, content, target: targetMember.user });
await message.delete().catch(this.client.logger.warn.bind(this.client.logger));
return this.send({ target: targetMember, staff: member, content, anon }).catch((err) => this.client.logger.error(`Error during Modmail.send:\n${err.stack}`));
}
// Send modmail with the modmail command
async sendModmail ({ message, content, anon, target }) {
const targetMember = await this.getMember(target.id);
if (!targetMember) return {
error: true,
msg: `Cannot find member.`
};
const { member: staff, author } = message;
// Send to channel in server & target
const sent = await this.send({ target: targetMember, staff, anon, content }).catch((err) => this.client.logger.error(`Error during Modmail.sendModmail:\n${err.stack}`));
if (sent.error) return sent;
// Inline response
await message.channel.send('Delivered.').catch((err) => this.client.logger.error(`Error during Modmail.sendModmail:\n${err.stack}`));
this.log({ author, action: `${author.tag} sent a message to ${targetMember.user.tag}`, content, target: targetMember.user });
}
async send ({ target, staff, anon, content }) {
const embed = {
author: {
name: anon ? `${this.mainServer.name.toUpperCase()} STAFF` : staff.user.tag,
// eslint-disable-next-line camelcase
icon_url: anon ? this.mainServer.iconURL({ dynamic: true }) : staff.user.displayAvatarURL({ dynamic: true })
},
description: content,
color: anon ? this.anonColor : staff.highestRoleColor
};
// Dm the user
const sent = await target.send({ embed }).catch((err) => {
this.client.logger.warn(`Error during DMing user: ${err.message}`);
return {
error: true,
msg: `Failed to send message to target.`
};
});
if (sent.error) return sent;
if (anon) embed.author = {
name: `${staff.user.tag} (ANON)`,
// eslint-disable-next-line camelcase
icon_url: staff.user.displayAvatarURL({ dynamic: true })
};
return this.channels.send(target, embed, { author: staff.id, content, timestamp: Date.now(), isReply: true, anon });
}
async changeReadState (message, args, state = 'read') {
const { author } = message;
if (!this.categories.includes(message.channel.parentID) && !args.length) return {
error: true,
msg: `This command only works in modmail channels without arguments.`
};
let response = null,
user = null;
if (args.length) {
// Eventually support marking several threads read at the same time
const [ id ] = args;
user = await this.client.resolveUser(id, true);
let channel = await this.client.resolveChannel(id);
if (channel) {
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) => {
return val === channel.id;
});
if (!result) return {
error: true,
msg: `That doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**`
};
user = await this.client.resolveUser(result[0]);
response = await this.channels.setReadState(user.id, channel, author, state);
} else if (user) {
const _ch = this.cache.channels[user.id];
if (_ch) channel = await this.client.resolveChannel(_ch);
response = await this.channels.setReadState(user.id, channel, author, state);
} else return `Could not resolve ${id} to a target.`;
}
if (!response) {
const { channel } = message;
const chCache = this.cache.channels;
const result = Object.entries(chCache).find(([ , val ]) => {
return val === channel.id;
});
if (!result) return {
error: true,
msg: `This doesn't seem to be a valid modmail channel. Cache might be out of sync. **[MISSING TARGET]**`
};
const [ userId ] = result;
user = await this.getUser(userId);
response = await this.channels.setReadState(userId, channel, author, state);
}
if (response.error) return response;
this.log({ author, action: `${author.tag} marked ${user.tag}'s thread as ${state}`, target: user });
return 'Done';
}
async sendReminder () {
await this.cache.verifyQueue();
const channel = this.reminderChannel;
const amount = this.queue.length;
if (!amount) {
if (this.lastReminder) {
await this.lastReminder.delete().catch(() => null);
this.lastReminder = null;
}
return;
}
const str = `${amount} modmail in queue.`;
this.client.logger.debug(`Sending modmail reminder, #mm: ${amount}`);
if (this.lastReminder) {
if (channel.lastMessage?.id === this.lastReminder?.id) return this.lastReminder.edit(str);
await this.lastReminder.delete();
}
this.lastReminder = await channel.send(str);
this.cache.misc.lastReminder = this.lastReminder.id;
}
async log ({ author, content, action, target }) {
const embed = {
author: {
name: action,
// eslint-disable-next-line camelcase
icon_url: author.displayAvatarURL({ dynamic: true })
},
description: content ? `\`\`\`${content}\`\`\`` : '',
color: this.mainServer.me.highestRoleColor
};
if (target) {
embed.footer = {
text: `Staff: ${author.id} | Target: ${target.id}`
};
}
this.logChannel.send({ embed }).catch((err) => this.client.logger.error(`Error during logging of modmail:\n${err.stack}`));
}
getCanned (name) {
return this.replies[name.toLowerCase()];
}
loadReplies () {
this.client.logger.info('Loading canned replies');
if (!fs.existsSync('./canned_replies.json')) return {};
return JSON.parse(fs.readFileSync('./canned_replies.json', { encoding: 'utf-8' }));
}
saveReplies () {
this.client.logger.info('Saving canned replies');
fs.writeFileSync('./canned_replies.json', JSON.stringify(this.replies));
}
disable (reason) {
this.disabled = true;
if (reason) this.disabledReason = reason;
else this.disabledReason = null;
this.cache.misc.disabled = true;
this.cache.misc.disabledReason = this.disabledReason;
this.cache.savePersistentCache();
}
enable () {
this.disabled = false;
this.cache.misc.disabled = false;
this.cache.savePersistentCache();
}
}
module.exports = Modmail;

46
structure/Registry.js Normal file
View File

@ -0,0 +1,46 @@
const { Collection } = require("discord.js");
const path = require('path');
const fs = require('fs');
class Registry {
constructor (client) {
this.client = client;
this.commands = new Collection();
}
find (name) {
return this.commands.find((c) => c.name === name.toLowerCase() || c.aliases?.includes(name.toLowerCase()));
}
loadCommands () {
const commandsDir = path.join(process.cwd(), 'structure', 'commands');
const files = fs.readdirSync(commandsDir);
for (const file of files) {
const commandPath = path.join(commandsDir, file);
const commandClass = require(commandPath);
if (typeof commandClass !== 'function') {
delete require.cache[commandPath];
continue;
}
const command = new commandClass(this.client);
if (this.commands.has(command.name)) this.client.logger.warn(`Command by name ${command.name} already exists, skipping duplicate at path ${commandPath}`);
else this.commands.set(command.name, command);
}
}
}
module.exports = Registry;

143
structure/Resolver.js Normal file
View File

@ -0,0 +1,143 @@
class Resolver {
constructor (client) {
this.client = client;
}
/**
* Resolve several user resolveables
*
* @param {Array<String>} [resolveables=[]] an array of user resolveables (name, id, tag)
* @param {Boolean} [strict=false] whether or not to attempt resolving by partial usernames
* @returns {Promise<Array<User>> || boolean} Array of resolved users or false if none were resolved
* @memberof Resolver
*/
async resolveUsers (resolveables = [], strict = false) {
if (typeof resolveables === 'string') resolveables = [ resolveables ];
if (resolveables.length === 0) return false;
const { users } = this.client;
const resolved = [];
for (const resolveable of resolveables) {
if ((/<@!?([0-9]{17,21})>/u).test(resolveable)) {
const [ , id ] = resolveable.match(/<@!?([0-9]{17,21})>/u);
const user = await users.fetch(id).catch((err) => {
if (err.code === 10013) return false;
// this.client.logger.warn(err); return false;
});
if (user) resolved.push(user);
} else if ((/(id:)?([0-9]{17,21})/u).test(resolveable)) {
const [ , , id ] = resolveable.match(/(id:)?([0-9]{17,21})/u);
const user = await users.fetch(id).catch((err) => {
if (err.code === 10013) return false;
// this.client.logger.warn(err); return false;
});
if (user) resolved.push(user);
} else if ((/^@?([\S\s]{1,32})#([0-9]{4})/u).test(resolveable)) {
const m = resolveable.match(/^@?([\S\s]{1,32})#([0-9]{4})/u);
const username = m[1].toLowerCase();
const discrim = m[2].toLowerCase();
const user = users.cache.sort((a, b) => a.username.length - b.username.length).filter((u) => u.username.toLowerCase() === username && u.discriminator === discrim).first();
if (user) resolved.push(user);
} else if (!strict) {
const name = resolveable.toLowerCase();
const user = users.cache.sort((a, b) => a.username.length - b.username.length).filter((u) => u.username.toLowerCase().includes(name)).first();
if (user) resolved.push(user);
}
}
return resolved.length ? resolved : false;
}
async resolveUser (resolveable, strict) {
if (!resolveable) return false;
if (resolveable instanceof Array) throw new Error('Resolveable cannot be of type Array, use resolveUsers for resolving arrays of users');
const result = await this.resolveUsers([ resolveable ], strict);
return result ? result[0] : false;
}
/**
* Resolve multiple channels
*
* @param {Array<String>} [resolveables=[]] an array of channel resolveables (name, id)
* @param {Guild} guild the guild in which to look for channels
* @param {Boolean} [strict=false] whether or not partial names are resolved
* @param {Function} [filter=()] filter the resolving channels
* @returns {Promise<Array<GuildChannel>> || Promise<Boolean>} an array of guild channels or false if none were resolved
* @memberof Resolver
*/
async resolveChannels (resolveables = [], strict = false, guild = null, filter = () => true) {
if (typeof resolveables === 'string') resolveables = [ resolveables ];
if (resolveables.length === 0) return false;
if (!guild) guild = this.client.mainServer;
const CM = guild.channels;
const resolved = [];
for (const resolveable of resolveables) {
const channel = CM.resolve(resolveable);
if (channel && filter(channel)) {
resolved.push(channel);
continue;
}
const name = /^#?([a-z0-9\-_0]+)/iu;
const id = /^<?#?([0-9]{17,22})>?/iu;
if (id.test(resolveable)) {
const match = resolveable.match(id);
const [ , ch ] = match;
// eslint-disable-next-line no-shadow
const channel = await this.client.channels.fetch(ch).catch((e) => { }); // eslint-disable-line no-empty, no-empty-function, no-unused-vars
if (channel && filter(channel)) resolved.push(channel);
} else if (name.test(resolveable)) {
const match = resolveable.match(name);
const ch = match[1].toLowerCase();
// eslint-disable-next-line no-shadow
const channel = CM.cache.sort((a, b) => a.name.length - b.name.length).filter(filter).filter((c) => {
if (!strict) return c.name.toLowerCase().includes(ch);
return c.name.toLowerCase() === ch;
}).first();
if (channel) resolved.push(channel);
}
}
return resolved.length > 0 ? resolved : false;
}
async resolveChannel (resolveable, strict, guild, filter) {
if (!resolveable) return false;
if (resolveable instanceof Array) throw new Error('Resolveable cannot be of type Array, use resolveChannels for resolving arrays of channels');
const result = await this.resolveChannels([ resolveable ], strict, guild, filter);
return result ? result[0] : false;
}
}
module.exports = Resolver;

158
structure/Util.js Normal file
View File

@ -0,0 +1,158 @@
const moment = require('moment');
const path = require('path');
const fs = require('fs');
const fetch = require('node-fetch');
const { Util: DiscordUtil } = require('discord.js');
class Util {
constructor () {
throw new Error("Class may not be instantiated.");
}
static paginate (items, page = 1, pageLength = 10) {
const maxPage = Math.ceil(items.length / pageLength);
if (page < 1) page = 1;
if (page > maxPage) page = maxPage;
const startIndex = (page - 1) * pageLength;
return {
items: items.length > pageLength ? items.slice(startIndex, startIndex + pageLength) : items,
page,
maxPage,
pageLength
};
}
static arrayIncludesAny (target, compareTo = []) {
if (!(compareTo instanceof Array)) compareTo = [ compareTo ];
for (const elem of compareTo) {
if (target.includes(elem)) return true;
}
return false;
}
static downloadAsBuffer (source) {
return new Promise((resolve, reject) => {
fetch(source).then((res) => {
if (res.ok) resolve(res.buffer());
else reject(res.statusText);
});
});
}
static readdirRecursive (directory) {
const result = [];
// eslint-disable-next-line no-shadow
(function read (directory) {
const files = fs.readdirSync(directory);
for (const file of files) {
const filePath = path.join(directory, file);
if (fs.statSync(filePath).isDirectory()) {
read(filePath);
} else {
result.push(filePath);
}
}
}(directory));
return result;
}
static wait (ms) {
return this.delayFor(ms);
}
static delayFor (ms) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
static escapeMarkdown (text, options) {
if (typeof text !== 'string') return text;
return DiscordUtil.escapeMarkdown(text, options);
}
static get formattingPatterns () {
return [
[ '\\*{1,3}([^*]*)\\*{1,3}', '$1' ],
[ '_{1,3}([^_]*)_{1,3}', '$1' ],
[ '`{1,3}([^`]*)`{1,3}', '$1' ],
[ '~~([^~])~~', '$1' ]
];
}
static removeMarkdown (content) {
if (!content) throw new Error('Missing content');
this.formattingPatterns.forEach(([ pattern, replacer ]) => {
content = content.replace(new RegExp(pattern, 'gu'), replacer);
});
return content.trim();
}
/**
* Sanitise user given regex; escapes unauthorised characters
*
* @static
* @param {string} input
* @param {string[]} [allowed=['?', '\\', '(', ')', '|']]
* @return {string} The sanitised expression
* @memberof Util
*/
static sanitiseRegex (input, allowed = [ '?', '\\', '(', ')', '|' ]) {
if (!input) throw new Error('Missing input');
const reg = new RegExp(`[${this.regChars.filter((char) => !allowed.includes(char)).join('')}]`, 'gu');
return input.replace(reg, '\\$&');
}
static get regChars () {
return [ '.', '+', '*', '?', '\\[', '\\]', '^', '$', '(', ')', '{', '}', '|', '\\\\', '-' ];
}
static escapeRegex (string) {
if (typeof string !== 'string') {
throw new Error("Invalid type sent to escapeRegex.");
}
return string
.replace(/[|\\{}()[\]^$+*?.]/gu, '\\$&')
.replace(/-/gu, '\\x2d');
}
static duration (seconds) {
const { plural } = this;
let s = 0,
m = 0,
h = 0,
d = 0,
w = 0;
s = Math.floor(seconds);
m = Math.floor(s / 60);
s %= 60;
h = Math.floor(m / 60);
m %= 60;
d = Math.floor(h / 24);
h %= 24;
w = Math.floor(d / 7);
d %= 7;
return `${w ? `${w} ${plural(w, 'week')} ` : ''}${d ? `${d} ${plural(d, 'day')} ` : ''}${h ? `${h} ${plural(h, 'hour')} ` : ''}${m ? `${m} ${plural(m, 'minute')} ` : ''}${s ? `${s} ${plural(s, 'second')} ` : ''}`.trim();
}
static plural (amt, word) {
if (amt === 1) return word;
return `${word}s`;
}
static get date () {
return moment().format("YYYY-MM-DD HH:mm:ss");
}
}
module.exports = Util;

View File

@ -0,0 +1,40 @@
class CacheHandler {
constructor (client) {
this.client = client;
}
load () {
throw new Error('Not implemented');
}
savePersistentCache () {
throw new Error('Not implemented');
}
saveModmailHistory () {
throw new Error('Not implemented');
}
loadModmailHistory () {
throw new Error('Not implemented');
}
verifyQueue () {
throw new Error('Not implemented');
}
get json () {
return {
queue: this.queue,
channels: this.channels,
lastActivity: this.lastActivity,
misc: this.misc
};
}
}
module.exports = CacheHandler;

View File

@ -0,0 +1,89 @@
const Command = require('../Command');
class CannedReply extends Command {
constructor (client) {
super(client, {
name: 'cannedreply',
aliases: [ 'cr', 'canned' ],
showUsage: true,
usage: `<canned response name>`
});
}
async execute (message, { args }) {
const [ first, second ] = args.map((a) => a);
// eslint-disable-next-line prefer-const
let { channel, content, _caller } = message,
anon = false;
content = content.replace(`${this.client.prefix}${_caller}`, '');
const op = args.shift().toLowerCase();
if (op === 'anon') {
anon = true;
content = content.replace(first, '');
} else if ([ 'create', 'delete' ].includes(op)) {
return this.createCanned(op, args, message);
} else if ([ 'list' ].includes(first.toLowerCase())) {
const list = Object.entries(this.client.modmail.replies);
let str = '';
if (second?.toLowerCase() !== '-here') channel = await message.author.createDM();
// eslint-disable-next-line no-shadow
for (const [ name, content ] of list) {
const substr = `**${name}:** ${content}\n`;
if (str.length + substr.length > 2000) {
await channel.send(str);
str = '';
}
str += substr;
}
if (str.length) return channel.send(str);
return '**__None__**';
}
return this.client.modmail.sendCannedResponse({ message, responseName: content.trim(), anon });
}
async createCanned (op, args, { channel, author }) {
if (args.length < 1) return {
error: true,
msg: 'Missing reply name'
};
const [ _name, ...rest ] = args;
const name = _name.toLowerCase();
const canned = this.client.modmail.replies;
let confirmation = null;
if (op === 'create') {
if (!rest.length) return {
error: true,
msg: 'Missing content'
};
if (canned[name]) {
confirmation = await this.client.prompt(`A canned reply by the name ${name} already exists, would you like to overwrite it?`, { channel, author });
if (!confirmation) return 'Timed out.';
confirmation = [ 'y', 'yes', 'ok' ].includes(confirmation.content.toLowerCase());
if (!confirmation) return 'Cancelled';
}
canned[name] = rest.join(' ');
} else {
delete canned[name];
}
this.client.modmail.saveReplies();
return `Updated ${_name}`;
}
}
module.exports = CannedReply;

View File

@ -0,0 +1,29 @@
const Command = require('../Command');
const Util = require('../Util');
class Ping extends Command {
constructor (client) {
super(client, {
name: 'disable',
aliases: [ 'enable' ]
});
}
async execute ({ author, member, _caller }, { clean }) {
const { sudo } = this.client._options;
const roleIds = member.roles.cache.map(r => r.id);
if (!Util.arrayIncludesAny(roleIds, sudo) && !sudo.includes(author.id)) return;
if (_caller === 'enable') this.client.modmail.enable();
else this.client.modmail.disable(clean);
return `:thumbsup: ${_caller}d`;
}
}
module.exports = Ping;

View File

@ -0,0 +1,60 @@
const { inspect } = require('util');
const { username } = require('os').userInfo();
const Command = require('../Command');
const Util = require('../Util');
class Eval extends Command {
constructor (client) {
super(client, {
name: 'eval',
aliases: [ 'e' ]
});
}
async execute (message, { clean }) {
const { sudo } = this.client._options;
const { guild, author, member, client, channel } = message; // eslint-disable-line no-unused-vars
const roleIds = member.roles.cache.map(r => r.id);
if (!Util.arrayIncludesAny(roleIds, sudo) && !sudo.includes(author.id)) return;
try {
let evaled = eval(clean); // eslint-disable-line no-eval
if (evaled instanceof Promise) evaled = await evaled;
if (typeof evaled !== 'string') evaled = inspect(evaled);
evaled = evaled
.replace(new RegExp(this.client.token, 'gu'), '<redacted>')
.replace(new RegExp(username, 'gu'), '<redacted>');
// if (args.log) guild._debugLog(`[${message.author.tag}] Evaluation Success: ${evaled}`);
if (evaled.length > 1850) {
evaled = `${evaled.substring(0, 1850)}...`;
}
await channel.send(
`Evaluation was successful.\`\`\`js\n${evaled}\`\`\``,
{ emoji: 'success' }
);
} catch (error) {
let msg = `${error}${error.stack ? `\n${error.stack}` : ''}`;
// if (args.log) guild._debugLog(`[${message.author.tag}] Evaluation Fail: ${msg}`);
if (msg.length > 2000) msg = `${msg.substring(0, 1900)}...`;
await channel.send(
`Evaluation failed.\`\`\`js\n${msg}\`\`\``,
{ emoji: 'failure' }
);
}
}
}
module.exports = Eval;

28
structure/commands/Id.js Normal file
View File

@ -0,0 +1,28 @@
const Command = require('../Command');
class ModmailID extends Command {
constructor (client) {
super(client, {
name: 'id',
aliases: [ 'mmid' ]
});
}
async execute (message, { args }) {
let channel = null;
if (args?.length) channel = await this.client.resolveChannel(args[0]);
else ({ channel } = message);
const result = this.client.getUserFromChannel(channel);
if (result.error) return result;
const [ userId ] = result;
return userId;
}
}
module.exports = ModmailID;

View File

@ -0,0 +1,67 @@
const Command = require('../Command');
const Util = require('../Util');
class Logs extends Command {
constructor (client) {
super(client, {
name: 'logs',
aliases: [ 'mmlogs', 'mmhistory', 'mmlog' ],
showUsage: true,
usage: '<user> [page]'
});
}
async execute (message, { args }) {
const user = await this.client.resolveUser(args[0]);
let pageNr = 1;
if (args[1]) {
const num = parseInt(args[1]);
if (isNaN(num)) return {
error: true,
msg: 'Invalid page number, must be number'
};
pageNr = num;
}
const { member, channel } = message;
const history = await this.client.cache.loadModmailHistory(user.id);
if (!history.length) return 'Not found in modmail DB';
const page = Util.paginate([ ...history ].filter((e) => !('readState' in e)).reverse(), pageNr, 10);
const embed = {
author: {
name: `${user.tag} modmail history`,
// eslint-disable-next-line camelcase
icon_url: user.displayAvatarURL({ dynamic: true })
},
footer: {
text: `${user.id} | Page ${page.page}/${page.maxPage}`
},
fields: [],
color: member.highestRoleColor
};
for (const entry of page.items) {
// eslint-disable-next-line no-shadow
const user = await this.client.resolveUser(entry.author);
let value = entry.content.substring(0, 1000) + (entry.content.length > 1000 ? '...' : '');
if (!value.length) value = entry.attachments.join('\n');
embed.fields.push({
name: `${user.tag}${entry.anon ? ' (ANON)' : ''} @ ${new Date(entry.timestamp).toUTCString()}`,
value
});
if (entry.attachments?.length && entry.content.length) embed.fields.push({
name: `Attachments`,
value: entry.attachments.join('\n')
});
}
await channel.send({ embed });
}
}
module.exports = Logs;

View File

@ -0,0 +1,19 @@
const Command = require('../Command');
class Markread extends Command {
constructor (client) {
super(client, {
name: 'markread'
});
}
async execute (message, { args }) {
return this.client.modmail.changeReadState(message, args);
}
}
module.exports = Markread;

View File

@ -0,0 +1,19 @@
const Command = require('../Command');
class Markunread extends Command {
constructor (client) {
super(client, {
name: 'markunread'
});
}
async execute (message, { args }) {
return this.client.modmail.changeReadState(message, args, 'unread');
}
}
module.exports = Markunread;

View File

@ -0,0 +1,55 @@
const { MessageAttachment } = require('discord.js');
const Command = require('../Command');
class MessageIds extends Command {
constructor (client) {
super(client, {
name: 'messageids',
aliases: [ 'msgids', 'mmids' ]
});
}
async execute (message, { args }) {
let channel = null;
if (args?.length) channel = await this.client.resolveChannel(args[0]);
else ({ channel } = message);
const result = this.client.getUserFromChannel(channel);
if (result.error) return result;
const [ userId ] = result;
const user = await this.client.users.fetch(userId);
const dmChannel = await user.createDM();
const history = await this.client.cache.loadModmailHistory(userId);
const sorted = history.sort((a, b) => b.timestamp - a.timestamp);
const idContentPairs = [];
const urlBase = `https://discord.com/channels/@me`;
for (const mm of sorted) {
if (!mm.msgid && !mm.isReply) break; // Old modmails from before msg id logging -- could probably supplement with fetching messages but cba rn
idContentPairs.push(`${urlBase}/${dmChannel.id}/${mm.msgid} - ${mm.content}`);
}
if (!idContentPairs.length) {
const msgs = await dmChannel.messages.fetch();
const sortedMsgs = msgs.filter(msg => msg.author.id !== this.client.user.id).sort((a, b) => b.createdTimestamp - a.createdTimestamp);
for (const msg of sortedMsgs.values()) idContentPairs.push(`${urlBase}/${dmChannel.id}/${msg.id} - ${msg.content}`);
}
await message.channel.send({
files: [
new MessageAttachment(
Buffer.from(`ID - Content pairs for\n${user.tag} - ${user.id}\n\n${idContentPairs.join('\n')}`),
'ids.txt'
)
]
});
}
}
module.exports = MessageIds;

View File

@ -0,0 +1,51 @@
const Command = require('../Command');
class Modmail extends Command {
constructor (client) {
super(client, {
name: 'modmail',
aliases: [ 'mm' ],
showUsage: true,
usage: `<user> <content>`
});
}
async execute (message, { args }) {
// eslint-disable-next-line prefer-const
let [ first, second ] = args.map((a) => a);
// eslint-disable-next-line prefer-const
let { content, _caller } = message,
anon = false;
content = content.replace(`${this.client.prefix}${_caller}`, '');
if (first.toLowerCase() === 'anon') {
anon = true;
content = content.replace(first, '');
first = second;
} else if (second?.toLowerCase() === 'anon') {
anon = true;
content = content.replace(second, '');
}
const user = await this.client.resolveUser(first, true);
if (!user) return {
error: true,
msg: 'Failed to resolve user'
};
else if (user.bot) return {
error: true,
msg: 'Cannot send modmail to a bot.'
};
content = content.replace(first, '').trim();
if (!content.length) return {
error: true,
msg: `Cannot send empty message`
};
return this.client.modmail.sendModmail({ message, content, anon, target: user });
}
}
module.exports = Modmail;

View File

@ -0,0 +1,17 @@
const Command = require('../Command');
class Ping extends Command {
constructor (client) {
super(client, {
name: 'ping'
});
}
async execute () {
return `PONG!`;
}
}
module.exports = Ping;

View File

@ -1,32 +1,26 @@
import { CommandResponse, ExtendedMessage } from '../../@types/Client.js';
import ModmailClient from '../Client.js';
import Command from '../Command.js';
const Command = require('../Command');
class Queue extends Command
{
constructor (client: ModmailClient)
{
class Queue extends Command {
constructor (client) {
super(client, {
name: 'queue',
aliases: [ 'mmq', 'mmqueue', 'q' ]
});
}
override async execute (message: ExtendedMessage): Promise<CommandResponse>
{
async execute (message) {
const { queue } = this.client.modmail;
if (!queue.length)
return 'Queue is empty!';
if (!queue.length) return 'Queue is empty!';
await this.client.cache.verifyQueue();
const users = await this.client.resolveUsers(queue);
let str = '',
let str = ``,
count = 0;
for (const user of users)
{
for (const user of users) {
const _str = `${user.tag} (${user.id})\n`;
if ((str + _str).length > 2000)
break;
if ((str + _str).length > 2000) break;
str += _str;
count++;
}
@ -40,9 +34,10 @@ class Queue extends Command
}
};
await channel.send({ embeds: [ embed ] });
await channel.send({ embed });
}
}
export default Queue;
module.exports = Queue;

View File

@ -0,0 +1,31 @@
const Command = require('../Command');
class Reply extends Command {
constructor (client) {
super(client, {
name: 'reply',
aliases: [ 'r' ],
showUsage: true,
usage: `<reply content>`
});
}
async execute (message, { args }) {
const [ first ] = args.map((a) => a);
// eslint-disable-next-line prefer-const
let { content, _caller } = message,
anon = false;
content = content.replace(`${this.client.prefix}${_caller}`, '');
if (first.toLowerCase() === 'anon') {
anon = true;
content = content.replace(first, '');
}
return this.client.modmail.sendResponse({ message, content: content.trim(), anon });
}
}
module.exports = Reply;

View File

@ -0,0 +1,19 @@
const { Structures } = require('discord.js');
const TextChannel = Structures.extend('TextChannel', (TextChannel) => {
return class ExtendedTextChannel extends TextChannel {
constructor(guild, data) {
super(guild, data);
this.answered = false;
this.recipient = null;
}
};
});
module.exports = TextChannel;

View File

@ -0,0 +1,15 @@
const { Structures } = require('discord.js');
const Member = Structures.extend('GuildMember', (GuildMember) => {
return class ExtendedMember extends GuildMember {
get highestRoleColor() {
const role = this.roles.cache.filter((role) => role.color !== 0).sort((a, b) => b.rawPosition - a.rawPosition).first();
if (role) return role.color;
return 0;
}
};
});
module.exports = Member;

View File

@ -0,0 +1,4 @@
module.exports = {
TextChannel: require('./Channel'),
GuildMember: require('./Member')
};

3
structure/index.js Normal file
View File

@ -0,0 +1,3 @@
module.exports = {
Client: require('./Client.js')
};

View File

@ -1,115 +0,0 @@
{
"compilerOptions": {
// "watch": true,
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
"lib": ["ES2022"], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "NodeNext", /* Specify what module code is generated. */
// "module": "AMD",
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "nodenext", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
"typeRoots": [
"./node_modules/@types",
"./@types/**"
], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
"allowJs": false, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
"inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./build/out-esm.js", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build", /* Specify an output folder for all emitted files. */
"removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
"stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
"strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
"noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
"noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"compileOnSave": true
}

5505
yarn.lock

File diff suppressed because it is too large Load Diff