Compare commits
No commits in common. "master" and "master" have entirely different histories.
@ -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"
|
||||
]
|
||||
}
|
||||
};
|
@ -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
|
@ -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
29
.gitignore
vendored
@ -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
|
||||
|
893
.yarn/releases/yarn-4.1.1.cjs
vendored
893
.yarn/releases/yarn-4.1.1.cjs
vendored
File diff suppressed because one or more lines are too long
@ -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
49
@types/Client.d.ts
vendored
@ -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
67
@types/Modmail.d.ts
vendored
@ -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
|
||||
}
|
18
Dockerfile
18
Dockerfile
@ -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
42
config.example.js
Normal 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: ''
|
||||
}
|
||||
}
|
||||
};
|
@ -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
12
index.js
Normal 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();
|
17
index.ts
17
index.ts
@ -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
77
logger/Logger.js
Normal 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
3
logger/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
Logger: require('./Logger')
|
||||
};
|
52
logger/transports/DiscordWebhook.js
Normal file
52
logger/transports/DiscordWebhook.js
Normal 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;
|
119
logger/transports/FileExtension.js
Normal file
119
logger/transports/FileExtension.js
Normal 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;
|
4
logger/transports/index.js
Normal file
4
logger/transports/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
DiscordWebhook: require('./DiscordWebhook.js'),
|
||||
FileExtension: require('./FileExtension.js')
|
||||
};
|
29
package.json
29
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
296
src/Client.ts
296
src/Client.ts
@ -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;
|
@ -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;
|
189
src/JsonCache.ts
189
src/JsonCache.ts
@ -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;
|
592
src/Modmail.ts
592
src/Modmail.ts
@ -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;
|
@ -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;
|
156
src/Resolver.ts
156
src/Resolver.ts
@ -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;
|
209
src/Util.ts
209
src/Util.ts
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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
312
structure/ChannelHandler.js
Normal 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
186
structure/Client.js
Normal 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
21
structure/Command.js
Normal 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
152
structure/JsonCache.js
Normal 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
455
structure/Modmail.js
Normal 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
46
structure/Registry.js
Normal 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
143
structure/Resolver.js
Normal 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
158
structure/Util.js
Normal 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;
|
40
structure/abstractions/CacheHandler.js
Normal file
40
structure/abstractions/CacheHandler.js
Normal 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;
|
89
structure/commands/CannedReply.js
Normal file
89
structure/commands/CannedReply.js
Normal 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;
|
29
structure/commands/Disable.js
Normal file
29
structure/commands/Disable.js
Normal 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;
|
60
structure/commands/Eval.js
Normal file
60
structure/commands/Eval.js
Normal 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
28
structure/commands/Id.js
Normal 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;
|
67
structure/commands/Logs.js
Normal file
67
structure/commands/Logs.js
Normal 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;
|
19
structure/commands/Markread.js
Normal file
19
structure/commands/Markread.js
Normal 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;
|
19
structure/commands/Markunread.js
Normal file
19
structure/commands/Markunread.js
Normal 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;
|
55
structure/commands/MessageIds.js
Normal file
55
structure/commands/MessageIds.js
Normal 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;
|
51
structure/commands/Modmail.js
Normal file
51
structure/commands/Modmail.js
Normal 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;
|
17
structure/commands/Ping.js
Normal file
17
structure/commands/Ping.js
Normal 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;
|
@ -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;
|
31
structure/commands/Reply.js
Normal file
31
structure/commands/Reply.js
Normal 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;
|
19
structure/extensions/Channel.js
Normal file
19
structure/extensions/Channel.js
Normal 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;
|
15
structure/extensions/Member.js
Normal file
15
structure/extensions/Member.js
Normal 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;
|
4
structure/extensions/index.js
Normal file
4
structure/extensions/index.js
Normal file
@ -0,0 +1,4 @@
|
||||
module.exports = {
|
||||
TextChannel: require('./Channel'),
|
||||
GuildMember: require('./Member')
|
||||
};
|
3
structure/index.js
Normal file
3
structure/index.js
Normal file
@ -0,0 +1,3 @@
|
||||
module.exports = {
|
||||
Client: require('./Client.js')
|
||||
};
|
115
tsconfig.json
115
tsconfig.json
@ -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
|
||||
}
|
Loading…
Reference in New Issue
Block a user