3
0
Fork 0
mirror of https://github.com/ZeppelinBot/Zeppelin.git synced 2025-05-20 16:25:03 +00:00

Merge pull request #1 from Dragory/master

h
This commit is contained in:
vcokltfre 2021-03-10 15:46:54 +00:00 committed by GitHub
commit 7dcdb5f72f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
143 changed files with 4708 additions and 419 deletions

View file

@ -89,6 +89,11 @@
"integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==", "integrity": "sha512-9NET910DNaIPngYnLLPeg+Ogzqsi9uM4mSboU5y6p8S5DzMTVEsJZrawi+BoDNUVBa2DhJqQYUFvMDfgU062LQ==",
"dev": true "dev": true
}, },
"@sqltools/formatter": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.2.tgz",
"integrity": "sha512-/5O7Fq6Vnv8L6ucmPjaWbVG1XkP4FO+w5glqfkIsq3Xw4oyNAdJddbnYodNDAfjVUvo/rrSCTom4kAND7T1o5Q=="
},
"@szmarczak/http-timer": { "@szmarczak/http-timer": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-1.1.2.tgz",
@ -359,12 +364,14 @@
"ansi-regex": { "ansi-regex": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz",
"integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==" "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==",
"dev": true
}, },
"ansi-styles": { "ansi-styles": {
"version": "3.2.1", "version": "3.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",
"integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",
"dev": true,
"requires": { "requires": {
"color-convert": "^1.9.0" "color-convert": "^1.9.0"
} }
@ -385,9 +392,9 @@
} }
}, },
"app-root-path": { "app-root-path": {
"version": "2.2.1", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-2.2.1.tgz", "resolved": "https://registry.npmjs.org/app-root-path/-/app-root-path-3.0.0.tgz",
"integrity": "sha512-91IFKeKk7FjfmezPKkwtaRvSpnUc4gDwPAjA1YZ9Gn0q0PPeW+vbeUsZuyDwjI7+QTHhcLen2v25fi/AmhvbJA==" "integrity": "sha512-qMcx+Gy2UZynHjOHOIXPNvpf+9cjvk3cWrBBK7zg4gH9+clobJRb9NGzcT7mQTcV/6Gm/1WelUtqxVXnNlrwcw=="
}, },
"aproba": { "aproba": {
"version": "1.2.0", "version": "1.2.0",
@ -726,9 +733,9 @@
"integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c="
}, },
"base64-js": { "base64-js": {
"version": "1.3.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==" "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
}, },
"base64url": { "base64url": {
"version": "3.0.1", "version": "3.0.1",
@ -922,12 +929,12 @@
} }
}, },
"buffer": { "buffer": {
"version": "5.4.3", "version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.4.3.tgz", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-zvj65TkFeIt3i6aj5bIvJDzjjQQGs4o/sNoezg1F1kYap9Nu2jcUdpwzRSJTHMMzG0H7bZkn4rNQpImhuxWX2A==", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"requires": { "requires": {
"base64-js": "^1.0.2", "base64-js": "^1.3.1",
"ieee754": "^1.1.4" "ieee754": "^1.1.13"
} }
}, },
"buffer-from": { "buffer-from": {
@ -990,12 +997,14 @@
"camelcase": { "camelcase": {
"version": "5.3.1", "version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==" "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true
}, },
"chalk": { "chalk": {
"version": "2.4.2", "version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
"integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",
"dev": true,
"requires": { "requires": {
"ansi-styles": "^3.2.1", "ansi-styles": "^3.2.1",
"escape-string-regexp": "^1.0.5", "escape-string-regexp": "^1.0.5",
@ -1069,15 +1078,61 @@
} }
}, },
"cli-highlight": { "cli-highlight": {
"version": "2.1.1", "version": "2.1.10",
"resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.1.tgz", "resolved": "https://registry.npmjs.org/cli-highlight/-/cli-highlight-2.1.10.tgz",
"integrity": "sha512-0y0VlNmdD99GXZHYnvrQcmHxP8Bi6T00qucGgBgGv4kJ0RyDthNnnFPupHV7PYv/OXSVk+azFbOeaW6+vGmx9A==", "integrity": "sha512-CcPFD3JwdQ2oSzy+AMG6j3LRTkNjM82kzcSKzoVw6cLanDCJNlsLjeqVTOTfOfucnWv5F0rmBemVf1m9JiIasw==",
"requires": { "requires": {
"chalk": "^2.3.0", "chalk": "^4.0.0",
"highlight.js": "^9.6.0", "highlight.js": "^10.0.0",
"mz": "^2.4.0", "mz": "^2.4.0",
"parse5": "^4.0.0", "parse5": "^5.1.1",
"yargs": "^13.0.0" "parse5-htmlparser2-tree-adapter": "^6.0.0",
"yargs": "^16.0.0"
},
"dependencies": {
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"requires": {
"color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"requires": {
"has-flag": "^4.0.0"
}
}
} }
}, },
"cli-spinners": { "cli-spinners": {
@ -1131,13 +1186,43 @@
} }
}, },
"cliui": { "cliui": {
"version": "5.0.0", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
"integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
"requires": { "requires": {
"string-width": "^3.1.0", "string-width": "^4.2.0",
"strip-ansi": "^5.2.0", "strip-ansi": "^6.0.0",
"wrap-ansi": "^5.1.0" "wrap-ansi": "^7.0.0"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"string-width": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"requires": {
"ansi-regex": "^5.0.0"
}
}
} }
}, },
"clone": { "clone": {
@ -1364,11 +1449,6 @@
"ms": "2.0.0" "ms": "2.0.0"
} }
}, },
"decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA="
},
"decompress-response": { "decompress-response": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-3.3.0.tgz",
@ -1403,14 +1483,6 @@
"integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==", "integrity": "sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==",
"dev": true "dev": true
}, },
"define-properties": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
"integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
"requires": {
"object-keys": "^1.0.12"
}
},
"del": { "del": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz", "resolved": "https://registry.npmjs.org/del/-/del-5.1.0.tgz",
@ -1587,32 +1659,10 @@
"is-arrayish": "^0.2.1" "is-arrayish": "^0.2.1"
} }
}, },
"es-abstract": { "escalade": {
"version": "1.16.0", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.16.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-xdQnfykZ9JMEiasTAJZJdMWCQ1Vm00NBw79/AWi7ELfZuuPCSOMDZbT9mkOfSctVtfhb+sAAzrm+j//GjjLHLg==", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
"requires": {
"es-to-primitive": "^1.2.0",
"function-bind": "^1.1.1",
"has": "^1.0.3",
"has-symbols": "^1.0.0",
"is-callable": "^1.1.4",
"is-regex": "^1.0.4",
"object-inspect": "^1.6.0",
"object-keys": "^1.1.1",
"string.prototype.trimleft": "^2.1.0",
"string.prototype.trimright": "^2.1.0"
}
},
"es-to-primitive": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.0.tgz",
"integrity": "sha512-qZryBOJjV//LaxLTV6UC//WewneB3LcXOL9NP++ozKVXsIIIpm/2c13UDiD9Jp2eThsecw9m3jPqDwTyobcdbg==",
"requires": {
"is-callable": "^1.1.4",
"is-date-object": "^1.0.1",
"is-symbol": "^1.0.2"
}
}, },
"escape-goat": { "escape-goat": {
"version": "2.1.1", "version": "2.1.1",
@ -1733,9 +1783,9 @@
} }
}, },
"figlet": { "figlet": {
"version": "1.2.4", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/figlet/-/figlet-1.2.4.tgz", "resolved": "https://registry.npmjs.org/figlet/-/figlet-1.5.0.tgz",
"integrity": "sha512-mv8YA9RruB4C5QawPaD29rEVx3N97ZTyNrE4DAfbhuo6tpcMdKnPVo8MlyT3RP5uPcg5M14bEJBq7kjFf4kAWg==" "integrity": "sha512-ZQJM4aifMpz6H19AW1VqvZ7l4pOE9p7i/3LyxgO2kp+PO/VcDYNqIHEMtkccqIhTXMKci4kjueJr/iCQEaT/Ww=="
}, },
"figures": { "figures": {
"version": "3.2.0", "version": "3.2.0",
@ -1848,11 +1898,6 @@
"dev": true, "dev": true,
"optional": true "optional": true
}, },
"function-bind": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
},
"gauge": { "gauge": {
"version": "2.7.4", "version": "2.7.4",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz",
@ -1924,6 +1969,7 @@
"version": "7.1.5", "version": "7.1.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.5.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.5.tgz",
"integrity": "sha512-J9dlskqUXK1OeTOYBEn5s8aMukWMwWfs+rPTn/jn50Ux4MNXVhubL1wu/j2t+H4NVI+cXEcCaYellqaPVGXNqQ==", "integrity": "sha512-J9dlskqUXK1OeTOYBEn5s8aMukWMwWfs+rPTn/jn50Ux4MNXVhubL1wu/j2t+H4NVI+cXEcCaYellqaPVGXNqQ==",
"dev": true,
"requires": { "requires": {
"fs.realpath": "^1.0.0", "fs.realpath": "^1.0.0",
"inflight": "^1.0.4", "inflight": "^1.0.4",
@ -1989,14 +2035,6 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.3.tgz",
"integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ==" "integrity": "sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ=="
}, },
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"requires": {
"function-bind": "^1.1.1"
}
},
"has-ansi": { "has-ansi": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz",
@ -2015,12 +2053,8 @@
"has-flag": { "has-flag": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
"integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=" "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=",
}, "dev": true
"has-symbols": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.0.tgz",
"integrity": "sha1-uhqPGvKg/DllD1yFA2dwQSIGO0Q="
}, },
"has-unicode": { "has-unicode": {
"version": "2.0.1", "version": "2.0.1",
@ -2034,9 +2068,9 @@
"dev": true "dev": true
}, },
"highlight.js": { "highlight.js": {
"version": "9.16.2", "version": "10.6.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.16.2.tgz", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.6.0.tgz",
"integrity": "sha512-feMUrVLZvjy0oC7FVJQcSQRqbBq9kwqnYE4+Kj9ZjbHh3g+BisiPgF49NyQbVLNdrL/qqZr3Ca9yOKwgn2i/tw==" "integrity": "sha512-8mlRcn5vk/r4+QcqerapwBYTe+iPL5ih6xrNylxrnBdHQiijDETfXX7VIxC3UiCRiINBJfANBAsPzAvRQj8RpQ=="
}, },
"hosted-git-info": { "hosted-git-info": {
"version": "2.8.8", "version": "2.8.8",
@ -2076,9 +2110,9 @@
} }
}, },
"ieee754": { "ieee754": {
"version": "1.1.13", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
}, },
"ignore": { "ignore": {
"version": "5.1.8", "version": "5.1.8",
@ -2170,11 +2204,6 @@
"binary-extensions": "^2.0.0" "binary-extensions": "^2.0.0"
} }
}, },
"is-callable": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.4.tgz",
"integrity": "sha512-r5p9sxJjYnArLjObpjA4xu5EKI3CuKHkJXMhT7kwbpUyIFD1n5PMAsoPvWnvtZiNz7LjkYDRZhd7FlI0eMijEA=="
},
"is-ci": { "is-ci": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz",
@ -2184,11 +2213,6 @@
"ci-info": "^2.0.0" "ci-info": "^2.0.0"
} }
}, },
"is-date-object": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.1.tgz",
"integrity": "sha1-mqIOtq7rv/d/vTPnTKAbM1gdOhY="
},
"is-error": { "is-error": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz",
@ -2273,22 +2297,6 @@
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"dev": true "dev": true
}, },
"is-regex": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.4.tgz",
"integrity": "sha1-VRdIm1RwkbCTDglWVM7SXul+lJE=",
"requires": {
"has": "^1.0.1"
}
},
"is-symbol": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.2.tgz",
"integrity": "sha512-HS8bZ9ox60yCJLH9snBpIwv9pYUAkcuLhSA1oero1UB5y9aiQpRA8y2ex945AOtCZL1lJDeIk3G5LthswI46Lw==",
"requires": {
"has-symbols": "^1.0.0"
}
},
"is-typedarray": { "is-typedarray": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz",
@ -2371,12 +2379,27 @@
} }
}, },
"knub": { "knub": {
"version": "30.0.0-beta.30", "version": "30.0.0-beta.35",
"resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.30.tgz", "resolved": "https://registry.npmjs.org/knub/-/knub-30.0.0-beta.35.tgz",
"integrity": "sha512-K0aUlPVORy+p2XkZe6qYOT/ekjM43TN2TmzY76rIgkz7RtxpX5boSYj6dt4omLz6o7B5rCCBaNq9poEO+l2gfw==", "integrity": "sha512-3fVefmp8hq8DxR8RuR/bqaFsXCkt2yf7NyotXaI8wq1FbPjM4/tM+YAzHIg9ER1L96KFZ9SDnRvs18DOb4Nk1w==",
"requires": { "requires": {
"knub-command-manager": "^8.1.2", "knub-command-manager": "^9.1.0",
"ts-essentials": "^6.0.7" "ts-essentials": "^6.0.7"
},
"dependencies": {
"escape-string-regexp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
"integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="
},
"knub-command-manager": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/knub-command-manager/-/knub-command-manager-9.1.0.tgz",
"integrity": "sha512-pEtpWElbBoTRSL8kWSPRrTIuTIdvYGkP/wzOn77cieumC02adfwEt1Cc09HFvVT4ib35nf1y31oul36csaG7Vg==",
"requires": {
"escape-string-regexp": "^2.0.0"
}
}
} }
}, },
"knub-command-manager": { "knub-command-manager": {
@ -2834,25 +2857,6 @@
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=" "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
}, },
"object-inspect": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.6.0.tgz",
"integrity": "sha512-GJzfBZ6DgDAmnuaM3104jR4s1Myxr3Y3zfIyN4z3UdqN69oSRacNK8UhnobDdC+7J2AHCjGwxQubNJfE70SXXQ=="
},
"object-keys": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
},
"object.getownpropertydescriptors": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.0.3.tgz",
"integrity": "sha1-h1jIRvW0B62rDyNuCYbxSwUcqhY=",
"requires": {
"define-properties": "^1.1.2",
"es-abstract": "^1.5.1"
}
},
"on-finished": { "on-finished": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -3071,9 +3075,24 @@
"dev": true "dev": true
}, },
"parse5": { "parse5": {
"version": "4.0.0", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
"integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==" "integrity": "sha512-ugq4DFI0Ptb+WWjAdOK16+u/nHfiIrcE+sh8kZMaM0WllQKLI9rOUq6c2b7cwPkXdzfQESqvoqK6ug7U/Yyzug=="
},
"parse5-htmlparser2-tree-adapter": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz",
"integrity": "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA==",
"requires": {
"parse5": "^6.0.1"
},
"dependencies": {
"parse5": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz",
"integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="
}
}
}, },
"parseurl": { "parseurl": {
"version": "1.3.3", "version": "1.3.3",
@ -3462,7 +3481,8 @@
"require-main-filename": { "require-main-filename": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==" "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
}, },
"resolve": { "resolve": {
"version": "1.17.0", "version": "1.17.0",
@ -3632,6 +3652,15 @@
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.1.1.tgz",
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==" "integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw=="
}, },
"sha.js": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"requires": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"sharp": { "sharp": {
"version": "0.23.4", "version": "0.23.4",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.23.4.tgz", "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.23.4.tgz",
@ -3876,6 +3905,7 @@
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz",
"integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==",
"dev": true,
"requires": { "requires": {
"emoji-regex": "^7.0.1", "emoji-regex": "^7.0.1",
"is-fullwidth-code-point": "^2.0.0", "is-fullwidth-code-point": "^2.0.0",
@ -3885,28 +3915,11 @@
"emoji-regex": { "emoji-regex": {
"version": "7.0.3", "version": "7.0.3",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz",
"integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==" "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==",
"dev": true
} }
} }
}, },
"string.prototype.trimleft": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.0.tgz",
"integrity": "sha512-FJ6b7EgdKxxbDxc79cOlok6Afd++TTs5szo+zJTUyow3ycrRfJVE2pq3vcN53XexvKZu/DJMDfeI/qMiZTrjTw==",
"requires": {
"define-properties": "^1.1.3",
"function-bind": "^1.1.1"
}
},
"string.prototype.trimright": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.0.tgz",
"integrity": "sha512-fXZTSV55dNBwv16uw+hh5jkghxSnc5oHq+5K/gXgizHwAvMetdAJlHqqoFC1FSDVPYWLkAKl2cxpUT41sV7nSg==",
"requires": {
"define-properties": "^1.1.3",
"function-bind": "^1.1.1"
}
},
"string_decoder": { "string_decoder": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
@ -3919,6 +3932,7 @@
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz",
"integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==",
"dev": true,
"requires": { "requires": {
"ansi-regex": "^4.1.0" "ansi-regex": "^4.1.0"
} }
@ -3984,6 +3998,7 @@
"version": "5.5.0", "version": "5.5.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
"integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",
"dev": true,
"requires": { "requires": {
"has-flag": "^3.0.0" "has-flag": "^3.0.0"
} }
@ -4056,9 +4071,9 @@
"dev": true "dev": true
}, },
"thenify": { "thenify": {
"version": "3.3.0", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.0.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha1-5p44obq+lpsBCCB5eLn2K4hgSDk=", "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"requires": { "requires": {
"any-promise": "^1.0.0" "any-promise": "^1.0.0"
} }
@ -4180,9 +4195,9 @@
} }
}, },
"tslib": { "tslib": {
"version": "1.10.0", "version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.10.0.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ==" "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="
}, },
"tunnel-agent": { "tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
@ -4239,43 +4254,115 @@
} }
}, },
"typeorm": { "typeorm": {
"version": "0.2.20", "version": "0.2.31",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.20.tgz", "resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.2.31.tgz",
"integrity": "sha512-VxB+9qH8D+PM19MIx18Zs3Fqv/ZINnnQvUGmBEiLYDrB9etdSdamgSTCIhWdFNndeJ6ldH4jbD0Z6HWsepMPlA==", "integrity": "sha512-dVvCEVHH48DG0QPXAKfo0l6ecQrl3A8ucGP4Yw4myz4YEDMProebTQo8as83uyES+nrwCbu3qdkL4ncC2+qcMA==",
"requires": { "requires": {
"app-root-path": "^2.0.1", "@sqltools/formatter": "1.2.2",
"buffer": "^5.1.0", "app-root-path": "^3.0.0",
"chalk": "^2.4.2", "buffer": "^5.5.0",
"cli-highlight": "^2.0.0", "chalk": "^4.1.0",
"cli-highlight": "^2.1.10",
"debug": "^4.1.1", "debug": "^4.1.1",
"dotenv": "^6.2.0", "dotenv": "^8.2.0",
"glob": "^7.1.2", "glob": "^7.1.6",
"js-yaml": "^3.13.1", "js-yaml": "^3.14.0",
"mkdirp": "^0.5.1", "mkdirp": "^1.0.4",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"tslib": "^1.9.0", "sha.js": "^2.4.11",
"xml2js": "^0.4.17", "tslib": "^1.13.0",
"xml2js": "^0.4.23",
"yargonaut": "^1.1.2", "yargonaut": "^1.1.2",
"yargs": "^13.2.1" "yargs": "^16.0.3"
}, },
"dependencies": { "dependencies": {
"debug": { "ansi-styles": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"requires": { "requires": {
"ms": "^2.1.1" "color-convert": "^2.0.1"
}
},
"chalk": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.0.tgz",
"integrity": "sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==",
"requires": {
"ansi-styles": "^4.1.0",
"supports-color": "^7.1.0"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"requires": {
"ms": "2.1.2"
} }
}, },
"dotenv": { "dotenv": {
"version": "6.2.0", "version": "8.2.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-6.2.0.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-8.2.0.tgz",
"integrity": "sha512-HygQCKUBSFl8wKQZBSemMywRWcEDNidvNbjGVyZu3nbZ8qq9ubiPoGLMdRDpfSrpkkm9BXYFkpKxxFX38o/76w==" "integrity": "sha512-8sJ78ElpbDJBHNeBzUbUVLsqKdccaa/BXF1uPTw3GrvQTBgrQrtObr2mUrE38vzYd8cEv+m/JBfDLioYcfXoaw=="
},
"glob": {
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz",
"integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==",
"requires": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.0.4",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"requires": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
}
},
"mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="
}, },
"ms": { "ms": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"requires": {
"has-flag": "^4.0.0"
}
} }
} }
}, },
@ -4396,15 +4483,6 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=" "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
}, },
"util.promisify": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/util.promisify/-/util.promisify-1.0.0.tgz",
"integrity": "sha512-i+6qA2MPhvoKLuxnJNpXAGhg7HphQOSUq2LKMZD0m15EiskXUkMvKdF4Uui0WYeCUGea+o2cw/ZuwehtfsrNkA==",
"requires": {
"define-properties": "^1.1.2",
"object.getownpropertydescriptors": "^2.0.3"
}
},
"utils-merge": { "utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@ -4456,7 +4534,8 @@
"which-module": { "which-module": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz",
"integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=" "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=",
"dev": true
}, },
"which-pm-runs": { "which-pm-runs": {
"version": "1.0.0", "version": "1.0.0",
@ -4539,13 +4618,64 @@
} }
}, },
"wrap-ansi": { "wrap-ansi": {
"version": "5.1.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"requires": { "requires": {
"ansi-styles": "^3.2.0", "ansi-styles": "^4.0.0",
"string-width": "^3.0.0", "string-width": "^4.1.0",
"strip-ansi": "^5.0.0" "strip-ansi": "^6.0.0"
},
"dependencies": {
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
},
"ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"requires": {
"color-convert": "^2.0.1"
}
},
"color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"requires": {
"color-name": "~1.1.4"
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"string-width": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"requires": {
"ansi-regex": "^5.0.0"
}
}
} }
}, },
"wrappy": { "wrappy": {
@ -4577,12 +4707,11 @@
"dev": true "dev": true
}, },
"xml2js": { "xml2js": {
"version": "0.4.22", "version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.22.tgz", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-MWTbxAQqclRSTnehWWe5nMKzI3VmJ8ltiJEco8akcC6j3miOhjjfzKum5sId+CWhfxdOs/1xauYr8/ZDBtQiRw==", "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"requires": { "requires": {
"sax": ">=0.6.0", "sax": ">=0.6.0",
"util.promisify": "~1.0.0",
"xmlbuilder": "~11.0.0" "xmlbuilder": "~11.0.0"
} }
}, },
@ -4603,7 +4732,8 @@
"y18n": { "y18n": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
"integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==" "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==",
"dev": true
}, },
"yallist": { "yallist": {
"version": "2.1.2", "version": "2.1.2",
@ -4664,30 +4794,58 @@
} }
}, },
"yargs": { "yargs": {
"version": "13.3.0", "version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.0.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
"integrity": "sha512-2eehun/8ALW8TLoIl7MVaRUrg+yCnenu8B4kBlRxj3GJGDKU1Og7sMXPNm1BYyM1DOJmTZ4YeN/Nwxv+8XJsUA==", "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
"requires": { "requires": {
"cliui": "^5.0.0", "cliui": "^7.0.2",
"find-up": "^3.0.0", "escalade": "^3.1.1",
"get-caller-file": "^2.0.1", "get-caller-file": "^2.0.5",
"require-directory": "^2.1.1", "require-directory": "^2.1.1",
"require-main-filename": "^2.0.0", "string-width": "^4.2.0",
"set-blocking": "^2.0.0", "y18n": "^5.0.5",
"string-width": "^3.0.0", "yargs-parser": "^20.2.2"
"which-module": "^2.0.0", },
"y18n": "^4.0.0", "dependencies": {
"yargs-parser": "^13.1.1" "ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg=="
},
"is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"string-width": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.0.tgz",
"integrity": "sha512-zUz5JD+tgqtuDjMhwIg5uFVV3dtqZ9yQJlZVfq4I01/K5Paj5UHj7VyrQOJvzawSVlKpObApbfD0Ed6yJc+1eg==",
"requires": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.0"
}
},
"strip-ansi": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.0.tgz",
"integrity": "sha512-AuvKTrTfQNYNIctbR1K/YGTR1756GycPsg7b9bdV9Duqur4gv6aKqHXah67Z8ImS7WEz5QVcOtlfW2rZEugt6w==",
"requires": {
"ansi-regex": "^5.0.0"
}
},
"y18n": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.5.tgz",
"integrity": "sha512-hsRUr4FFrvhhRH12wOdfs38Gy7k2FFzB9qgN9v3aLykRq0dRcdcpz5C9FxdS2NuhOrI/628b/KSTJ3rwHysYSg=="
}
} }
}, },
"yargs-parser": { "yargs-parser": {
"version": "13.1.1", "version": "20.2.5",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.1.tgz", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.5.tgz",
"integrity": "sha512-oVAVsHz6uFrg3XQheFII8ESO2ssAf9luWuAd6Wexsu4F3OtIW0o8IribPXYrD4WC24LWtPrJlGy87y5udK+dxQ==", "integrity": "sha512-jYRGS3zWy20NtDtK2kBgo/TlAoy5YUuhD9/LZ7z7W4j1Fdw2cqD0xEEclf8fxc8xjD6X5Qr+qQQwCEsP8iRiYg=="
"requires": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
}
}, },
"yawn-yaml": { "yawn-yaml": {
"version": "github:dragory/yawn-yaml#77ab3870ca53c4693002c4a41336e7476e7934ed", "version": "github:dragory/yawn-yaml#77ab3870ca53c4693002c4a41336e7476e7934ed",

View file

@ -7,10 +7,10 @@
"watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"", "watch": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node start-dev.js\"",
"watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"", "watch-yaml-parse-test": "cross-env NODE_ENV=development tsc-watch --onSuccess \"node dist/backend/src/yamlParseTest.js\"",
"build": "rimraf dist && tsc", "build": "rimraf dist && tsc",
"start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9229 dist/backend/src/index.js", "start-bot-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9229 dist/backend/src/index.js",
"start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js", "start-bot-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/index.js",
"watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"", "watch-bot": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-bot-dev\"",
"start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=127.0.0.1:9239 dist/backend/src/api/index.js", "start-api-dev": "cross-env NODE_ENV=development node -r ./register-tsconfig-paths.js --unhandled-rejections=strict --inspect=0.0.0.0:9239 dist/backend/src/api/index.js",
"start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js", "start-api-prod": "cross-env NODE_ENV=production node -r ./register-tsconfig-paths.js --unhandled-rejections=strict dist/backend/src/api/index.js",
"watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"", "watch-api": "cross-env NODE_ENV=development tsc-watch --onSuccess \"npm run start-api-dev\"",
"typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js", "typeorm": "node -r ./register-tsconfig-paths.js ./node_modules/typeorm/cli.js",
@ -39,7 +39,7 @@
"humanize-duration": "^3.15.0", "humanize-duration": "^3.15.0",
"io-ts": "^2.0.0", "io-ts": "^2.0.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"knub": "^30.0.0-beta.30", "knub": "^30.0.0-beta.35",
"knub-command-manager": "^8.1.2", "knub-command-manager": "^8.1.2",
"last-commit-log": "^2.1.0", "last-commit-log": "^2.1.0",
"lodash.chunk": "^4.2.0", "lodash.chunk": "^4.2.0",
@ -65,7 +65,7 @@
"tmp": "0.0.33", "tmp": "0.0.33",
"tsconfig-paths": "^3.9.0", "tsconfig-paths": "^3.9.0",
"twemoji": "^12.1.4", "twemoji": "^12.1.4",
"typeorm": "^0.2.14", "typeorm": "^0.2.31",
"uuid": "^3.3.2", "uuid": "^3.3.2",
"yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build", "yawn-yaml": "github:dragory/yawn-yaml#string-number-fix-build",
"zlib-sync": "^0.1.7" "zlib-sync": "^0.1.7"

View file

@ -7,6 +7,8 @@ export enum ERRORS {
NO_USER_NOTIFICATION_CHANNEL, NO_USER_NOTIFICATION_CHANNEL,
INVALID_USER_NOTIFICATION_CHANNEL, INVALID_USER_NOTIFICATION_CHANNEL,
INVALID_USER, INVALID_USER,
INVALID_MUTE_ROLE_ID,
MUTE_ROLE_ABOVE_ZEP,
} }
export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = { export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = {
@ -16,6 +18,8 @@ export const RECOVERABLE_PLUGIN_ERROR_MESSAGES = {
[ERRORS.NO_USER_NOTIFICATION_CHANNEL]: "No user notify channel specified", [ERRORS.NO_USER_NOTIFICATION_CHANNEL]: "No user notify channel specified",
[ERRORS.INVALID_USER_NOTIFICATION_CHANNEL]: "Invalid user notify channel specified", [ERRORS.INVALID_USER_NOTIFICATION_CHANNEL]: "Invalid user notify channel specified",
[ERRORS.INVALID_USER]: "Invalid user", [ERRORS.INVALID_USER]: "Invalid user",
[ERRORS.INVALID_MUTE_ROLE_ID]: "Specified mute role is not invalid",
[ERRORS.MUTE_ROLE_ABOVE_ZEP]: "Specified mute role is above Zeppelin in the role hierarchy",
}; };
export class RecoverablePluginError extends Error { export class RecoverablePluginError extends Error {

View file

@ -19,6 +19,8 @@
"MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**", "MEMBER_NICK_CHANGE": "✏ {userMention(member)}: nickname changed from **{oldNick}** to **{newNick}**",
"MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**", "MEMBER_USERNAME_CHANGE": "✏ {userMention(user)}: username changed from **{oldName}** to **{newName}**",
"MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin", "MEMBER_RESTORE": "💿 Restored {restoredData} for {userMention(member)} on rejoin",
"MEMBER_TIMED_BAN": "🔨 {userMention(user)} was tempbanned by {userMention(mod)} for {banTime}",
"MEMBER_TIMED_UNBAN": "🔓 User (`{userId}`) was automatically unbanned by {userMention(mod)} after a tempban for {banTime}",
"CHANNEL_CREATE": "🖊 Channel {channelMention(channel)} was created", "CHANNEL_CREATE": "🖊 Channel {channelMention(channel)} was created",
"CHANNEL_DELETE": "🗑 Channel {channelMention(channel)} was deleted", "CHANNEL_DELETE": "🗑 Channel {channelMention(channel)} was deleted",
@ -38,6 +40,7 @@
"VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**", "VOICE_CHANNEL_MOVE": "🎙 ↔ {userMention(member)} moved from **{oldChannel.name}** to **{newChannel.name}**",
"VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left **{channel.name}**", "VOICE_CHANNEL_LEAVE": "🎙 🔴 {userMention(member)} left **{channel.name}**",
"VOICE_CHANNEL_FORCE_MOVE": "\uD83C\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}", "VOICE_CHANNEL_FORCE_MOVE": "\uD83C\uDF99 ✍ {userMention(member)} was moved from **{oldChannel.name}** to **{newChannel.name}** by {userMention(mod)}",
"VOICE_CHANNEL_FORCE_DISCONNECT": "\uD83C\uDF99 🚫 {userMention(member)} was forcefully disconnected from **{oldChannel.name}** by {userMention(mod)}",
"COMMAND": "🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`", "COMMAND": "🤖 {userMention(member)} used command in {channelMention(channel)}:\n`{command}`",
@ -49,6 +52,7 @@
"CASE_CREATE": "✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})", "CASE_CREATE": "✏ {userMention(mod)} manually created new **{caseType}** case (#{caseNum})",
"CASE_DELETE": "✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}", "CASE_DELETE": "✂️ **Case #{case.case_number}** was deleted by {userMention(mod)}",
"MASSUNBAN": "⚒ {userMention(mod)} mass-unbanned {count} users",
"MASSBAN": "⚒ {userMention(mod)} massbanned {count} users", "MASSBAN": "⚒ {userMention(mod)} massbanned {count} users",
"MASSMUTE": "📢🚫 {userMention(mod)} massmuted {count} users", "MASSMUTE": "📢🚫 {userMention(mod)} massmuted {count} users",

View file

@ -0,0 +1,501 @@
import { BaseGuildRepository } from "./BaseGuildRepository";
import { getRepository, In, IsNull, LessThan, Not, Repository } from "typeorm";
import { Counter } from "./entities/Counter";
import { CounterValue } from "./entities/CounterValue";
import { CounterTrigger, TRIGGER_COMPARISON_OPS, TriggerComparisonOp } from "./entities/CounterTrigger";
import { CounterTriggerState } from "./entities/CounterTriggerState";
import moment from "moment-timezone";
import { DAYS, DBDateFormat, HOURS, MINUTES } from "../utils";
import { connection } from "./db";
const comparisonStringRegex = new RegExp(`^(${TRIGGER_COMPARISON_OPS.join("|")})([1-9]\\d*)$`);
/**
* @return Parsed comparison op and value, or null if the comparison string was invalid
*/
export function parseCondition(str: string): [TriggerComparisonOp, number] | null {
const matches = str.match(comparisonStringRegex);
return matches ? [matches[1] as TriggerComparisonOp, parseInt(matches[2], 10)] : null;
}
export function buildConditionString(comparisonOp: TriggerComparisonOp, comparisonValue: number): string {
return `${comparisonOp}${comparisonValue}`;
}
function isValidComparisonOp(op: string): boolean {
return TRIGGER_COMPARISON_OPS.includes(op as any);
}
const REVERSE_OPS: Record<TriggerComparisonOp, TriggerComparisonOp> = {
"=": "!=",
"!=": "=",
">": "<=",
"<": ">=",
">=": "<",
"<=": ">",
};
function getReverseComparisonOp(op: TriggerComparisonOp): TriggerComparisonOp {
return REVERSE_OPS[op];
}
const DELETE_UNUSED_COUNTERS_AFTER = 1 * DAYS;
const DELETE_UNUSED_COUNTER_TRIGGERS_AFTER = 1 * DAYS;
const MAX_COUNTER_VALUE = 2147483647; // 2^31-1, for MySQL INT
async function deleteCountersMarkedToBeDeleted(): Promise<void> {
await getRepository(Counter)
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
}
async function deleteTriggersMarkedToBeDeleted(): Promise<void> {
await getRepository(CounterTrigger)
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
}
setInterval(deleteCountersMarkedToBeDeleted, 1 * HOURS);
setInterval(deleteTriggersMarkedToBeDeleted, 1 * HOURS);
setTimeout(deleteCountersMarkedToBeDeleted, 1 * MINUTES);
setTimeout(deleteTriggersMarkedToBeDeleted, 1 * MINUTES);
export class GuildCounters extends BaseGuildRepository {
private counters: Repository<Counter>;
private counterValues: Repository<CounterValue>;
private counterTriggers: Repository<CounterTrigger>;
private counterTriggerStates: Repository<CounterTriggerState>;
constructor(guildId) {
super(guildId);
this.counters = getRepository(Counter);
this.counterValues = getRepository(CounterValue);
this.counterTriggers = getRepository(CounterTrigger);
this.counterTriggerStates = getRepository(CounterTriggerState);
}
async findOrCreateCounter(name: string, perChannel: boolean, perUser: boolean): Promise<Counter> {
const existing = await this.counters.findOne({
where: {
guild_id: this.guildId,
name,
},
});
if (existing) {
// If the existing counter's properties match the ones we're looking for, return it.
// Otherwise, delete the existing counter and re-create it with the proper properties.
if (existing.per_channel === perChannel && existing.per_user === perUser) {
return existing;
}
await this.counters.delete({ id: existing.id });
}
const insertResult = await this.counters.insert({
guild_id: this.guildId,
name,
per_channel: perChannel,
per_user: perUser,
last_decay_at: moment.utc().format(DBDateFormat),
});
return (await this.counters.findOne({
where: {
id: insertResult.identifiers[0].id,
},
}))!;
}
async markUnusedCountersToBeDeleted(idsToKeep: number[]): Promise<void> {
if (idsToKeep.length === 0) {
return;
}
const deleteAt = moment
.utc()
.add(DELETE_UNUSED_COUNTERS_AFTER, "ms")
.format(DBDateFormat);
await this.counters.update(
{
guild_id: this.guildId,
id: Not(In(idsToKeep)),
delete_at: IsNull(),
},
{
delete_at: deleteAt,
},
);
}
async deleteCountersMarkedToBeDeleted(): Promise<void> {
await this.counters
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
}
async changeCounterValue(id: number, channelId: string | null, userId: string | null, change: number): Promise<void> {
if (typeof change !== "number" || Number.isNaN(change) || !Number.isFinite(change)) {
throw new Error(`changeCounterValue() change argument must be a number`);
}
channelId = channelId || "0";
userId = userId || "0";
const rawUpdate =
change >= 0 ? `value = LEAST(value + ${change}, ${MAX_COUNTER_VALUE})` : `value = GREATEST(value ${change}, 0)`;
await this.counterValues.query(
`
INSERT INTO counter_values (counter_id, channel_id, user_id, value)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ${rawUpdate}
`,
[id, channelId, userId, Math.max(change, 0)],
);
}
async setCounterValue(id: number, channelId: string | null, userId: string | null, value: number): Promise<void> {
if (typeof value !== "number" || Number.isNaN(value) || !Number.isFinite(value)) {
throw new Error(`setCounterValue() value argument must be a number`);
}
channelId = channelId || "0";
userId = userId || "0";
value = Math.max(value, 0);
await this.counterValues.query(
`
INSERT INTO counter_values (counter_id, channel_id, user_id, value)
VALUES (?, ?, ?, ?)
ON DUPLICATE KEY UPDATE value = ?
`,
[id, channelId, userId, value, value],
);
}
async decay(id: number, decayPeriodMs: number, decayAmount: number) {
const counter = (await this.counters.findOne({
where: {
id,
},
}))!;
const diffFromLastDecayMs = moment.utc().diff(moment.utc(counter.last_decay_at!), "ms");
if (diffFromLastDecayMs < decayPeriodMs) {
return;
}
const decayAmountToApply = Math.round((diffFromLastDecayMs / decayPeriodMs) * decayAmount);
if (decayAmountToApply === 0) {
return;
}
// Calculate new last_decay_at based on the rounded decay amount we applied. This makes it so that over time, the decayed amount will stay accurate, even if we round some here.
const newLastDecayDate = moment
.utc(counter.last_decay_at)
.add((decayAmountToApply / decayAmount) * decayPeriodMs, "ms")
.format(DBDateFormat);
const rawUpdate =
decayAmountToApply >= 0
? `GREATEST(value - ${decayAmountToApply}, 0)`
: `LEAST(value + ${Math.abs(decayAmountToApply)}, ${MAX_COUNTER_VALUE})`;
await this.counterValues.update(
{
counter_id: id,
},
{
value: () => rawUpdate,
},
);
await this.counters.update(
{
id,
},
{
last_decay_at: newLastDecayDate,
},
);
}
async markAllTriggersTobeDeleted() {
const deleteAt = moment
.utc()
.add(DELETE_UNUSED_COUNTER_TRIGGERS_AFTER, "ms")
.format(DBDateFormat);
await this.counterTriggers.update(
{},
{
delete_at: deleteAt,
},
);
}
async deleteTriggersMarkedToBeDeleted(): Promise<void> {
await this.counterTriggers
.createQueryBuilder()
.where("delete_at <= NOW()")
.delete()
.execute();
}
async initCounterTrigger(
counterId: number,
comparisonOp: TriggerComparisonOp,
comparisonValue: number,
): Promise<CounterTrigger> {
if (!isValidComparisonOp(comparisonOp)) {
throw new Error(`Invalid comparison op: ${comparisonOp}`);
}
if (typeof comparisonValue !== "number") {
throw new Error(`Invalid comparison value: ${comparisonValue}`);
}
return connection.transaction(async entityManager => {
const existing = await entityManager.findOne(CounterTrigger, {
counter_id: counterId,
comparison_op: comparisonOp,
comparison_value: comparisonValue,
});
if (existing) {
// Since all existing triggers are marked as to-be-deleted before they are re-initialized, this needs to be reset
await entityManager.update(CounterTrigger, existing.id, { delete_at: null });
return existing;
}
const insertResult = await entityManager.insert(CounterTrigger, {
counter_id: counterId,
comparison_op: comparisonOp,
comparison_value: comparisonValue,
});
return (await entityManager.findOne(CounterTrigger, insertResult.identifiers[0].id))!;
});
}
/**
* Checks if a counter value with the given parameters triggers the specified comparison for the specified counter.
* If it does, mark this comparison for these parameters as triggered.
* Note that if this comparison for these parameters was already triggered previously, this function will return false.
* This means that a specific comparison for the specific parameters specified will only trigger *once* until the reverse trigger is triggered.
*
* @param counterId
* @param comparisonOp
* @param comparisonValue
* @param userId
* @param channelId
* @return Whether the given parameters newly triggered the given comparison
*/
async checkForTrigger(
counterTrigger: CounterTrigger,
channelId: string | null,
userId: string | null,
): Promise<boolean> {
channelId = channelId || "0";
userId = userId || "0";
return connection.transaction(async entityManager => {
const previouslyTriggered = await entityManager.findOne(CounterTriggerState, {
trigger_id: counterTrigger.id,
user_id: userId!,
channel_id: channelId!,
});
if (previouslyTriggered) {
return false;
}
const matchingValue = await entityManager
.createQueryBuilder(CounterValue, "cv")
.leftJoin(
CounterTriggerState,
"triggerStates",
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id",
{ triggerId: counterTrigger.id },
)
.where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value })
.andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })
.andWhere("cv.channel_id = :channelId AND cv.user_id = :userId", { channelId, userId })
.andWhere("triggerStates.id IS NULL")
.getOne();
if (matchingValue) {
await entityManager.insert(CounterTriggerState, {
trigger_id: counterTrigger.id,
user_id: userId!,
channel_id: channelId!,
});
return true;
}
return false;
});
}
/**
* Checks if any counter values of the specified counter match the specified comparison.
* Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the reverse trigger is triggered for those values.
*
* @return Counter value parameters that triggered the condition
*/
async checkAllValuesForTrigger(
counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async entityManager => {
const matchingValues = await entityManager
.createQueryBuilder(CounterValue, "cv")
.leftJoin(
CounterTriggerState,
"triggerStates",
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id",
{ triggerId: counterTrigger.id },
)
.where(`cv.value ${counterTrigger.comparison_op} :value`, { value: counterTrigger.comparison_value })
.andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })
.andWhere("triggerStates.id IS NULL")
.getMany();
if (matchingValues.length) {
await entityManager.insert(
CounterTriggerState,
matchingValues.map(row => ({
trigger_id: counterTrigger.id,
channelId: row.channel_id,
userId: row.user_id,
})),
);
}
return matchingValues.map(row => ({
channelId: row.channel_id,
userId: row.user_id,
}));
});
}
/**
* Checks if a counter value with the given parameters *no longer* matches the specified comparison, and thus triggers a "reverse trigger".
* Like checkForTrigger(), this can only happen *once* until the comparison is triggered normally again.
*
* @param counterId
* @param comparisonOp
* @param comparisonValue
* @param userId
* @param channelId
* @return Whether the given parameters triggered a reverse trigger for the given comparison
*/
async checkForReverseTrigger(
counterTrigger: CounterTrigger,
channelId: string | null,
userId: string | null,
): Promise<boolean> {
channelId = channelId || "0";
userId = userId || "0";
return connection.transaction(async entityManager => {
const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op);
const matchingValue = await entityManager
.createQueryBuilder(CounterValue, "cv")
.innerJoin(
CounterTriggerState,
"triggerStates",
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id",
{ triggerId: counterTrigger.id },
)
.where(`cv.value ${reverseOp} :value`, { value: counterTrigger.comparison_value })
.andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })
.andWhere(`cv.channel_id = :channelId AND cv.user_id = :userId`, { channelId, userId })
.getOne();
if (matchingValue) {
await entityManager.delete(CounterTriggerState, {
trigger_id: counterTrigger.id,
user_id: userId!,
channel_id: channelId!,
});
return true;
}
return false;
});
}
/**
* Checks if any counter values of the specified counter *no longer* match the specified comparison, and thus triggers a "reverse trigger" for those values.
* Like checkForTrigger(), this can only happen *once* per unique counter value parameters until the comparison is triggered normally again.
*
* @return Counter value parameters that triggered a reverse trigger
*/
async checkAllValuesForReverseTrigger(
counterTrigger: CounterTrigger,
): Promise<Array<{ channelId: string; userId: string }>> {
return connection.transaction(async entityManager => {
const reverseOp = getReverseComparisonOp(counterTrigger.comparison_op);
const matchingValues: Array<{
id: string;
triggerStateId: string;
user_id: string;
channel_id: string;
}> = await entityManager
.createQueryBuilder(CounterValue, "cv")
.innerJoin(
CounterTriggerState,
"triggerStates",
"triggerStates.trigger_id = :triggerId AND triggerStates.user_id = cv.user_id AND triggerStates.channel_id = cv.channel_id",
{ triggerId: counterTrigger.id },
)
.where(`cv.value ${reverseOp} :value`, { value: counterTrigger.comparison_value })
.andWhere(`cv.counter_id = :counterId`, { counterId: counterTrigger.counter_id })
.select([
"cv.id AS id",
"cv.user_id AS user_id",
"cv.channel_id AS channel_id",
"triggerStates.id AS triggerStateId",
])
.getRawMany();
if (matchingValues.length) {
await entityManager.delete(CounterTriggerState, {
id: In(matchingValues.map(v => v.triggerStateId)),
});
}
return matchingValues.map(row => ({
channelId: row.channel_id,
userId: row.user_id,
}));
});
}
async getCurrentValue(
counterId: number,
channelId: string | null,
userId: string | null,
): Promise<number | undefined> {
const value = await this.counterValues.findOne({
where: {
counter_id: counterId,
channel_id: channelId || "0",
user_id: userId || "0",
},
});
return value?.value;
}
}

View file

@ -34,7 +34,7 @@ export class GuildMutes extends BaseGuildRepository {
return mute != null; return mute != null;
} }
async addMute(userId, expiryTime): Promise<Mute> { async addMute(userId, expiryTime, rolesToRestore?: string[]): Promise<Mute> {
const expiresAt = expiryTime const expiresAt = expiryTime
? moment ? moment
.utc() .utc()
@ -46,12 +46,13 @@ export class GuildMutes extends BaseGuildRepository {
guild_id: this.guildId, guild_id: this.guildId,
user_id: userId, user_id: userId,
expires_at: expiresAt, expires_at: expiresAt,
roles_to_restore: rolesToRestore ?? [],
}); });
return (await this.mutes.findOne({ where: result.identifiers[0] }))!; return (await this.mutes.findOne({ where: result.identifiers[0] }))!;
} }
async updateExpiryTime(userId, newExpiryTime) { async updateExpiryTime(userId, newExpiryTime, rolesToRestore?: string[]) {
const expiresAt = newExpiryTime const expiresAt = newExpiryTime
? moment ? moment
.utc() .utc()
@ -59,15 +60,28 @@ export class GuildMutes extends BaseGuildRepository {
.format("YYYY-MM-DD HH:mm:ss") .format("YYYY-MM-DD HH:mm:ss")
: null; : null;
return this.mutes.update( if (rolesToRestore && rolesToRestore.length) {
{ return this.mutes.update(
guild_id: this.guildId, {
user_id: userId, guild_id: this.guildId,
}, user_id: userId,
{ },
expires_at: expiresAt, {
}, expires_at: expiresAt,
); roles_to_restore: rolesToRestore,
},
);
} else {
return this.mutes.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
expires_at: expiresAt,
},
);
}
} }
async getActiveMutes(): Promise<Mute[]> { async getActiveMutes(): Promise<Mute[]> {

View file

@ -0,0 +1,75 @@
import moment from "moment-timezone";
import { Mute } from "./entities/Mute";
import { BaseGuildRepository } from "./BaseGuildRepository";
import { Brackets, getRepository, Repository } from "typeorm";
import { Tempban } from "./entities/Tempban";
export class GuildTempbans extends BaseGuildRepository {
private tempbans: Repository<Tempban>;
constructor(guildId) {
super(guildId);
this.tempbans = getRepository(Tempban);
}
async getExpiredTempbans(): Promise<Tempban[]> {
return this.tempbans
.createQueryBuilder("mutes")
.where("guild_id = :guild_id", { guild_id: this.guildId })
.andWhere("expires_at IS NOT NULL")
.andWhere("expires_at <= NOW()")
.getMany();
}
async findExistingTempbanForUserId(userId: string): Promise<Tempban | undefined> {
return this.tempbans.findOne({
where: {
guild_id: this.guildId,
user_id: userId,
},
});
}
async addTempban(userId, expiryTime, modId): Promise<Tempban> {
const expiresAt = moment
.utc()
.add(expiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss");
const result = await this.tempbans.insert({
guild_id: this.guildId,
user_id: userId,
mod_id: modId,
expires_at: expiresAt,
created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
});
return (await this.tempbans.findOne({ where: result.identifiers[0] }))!;
}
async updateExpiryTime(userId, newExpiryTime, modId) {
const expiresAt = moment
.utc()
.add(newExpiryTime, "ms")
.format("YYYY-MM-DD HH:mm:ss");
return this.tempbans.update(
{
guild_id: this.guildId,
user_id: userId,
},
{
created_at: moment.utc().format("YYYY-MM-DD HH:mm:ss"),
expires_at: expiresAt,
mod_id: modId,
},
);
}
async clear(userId) {
await this.tempbans.delete({
guild_id: this.guildId,
user_id: userId,
});
}
}

View file

@ -39,17 +39,21 @@ export enum LogType {
CASE_CREATE, CASE_CREATE,
MASSUNBAN,
MASSBAN, MASSBAN,
MASSMUTE, MASSMUTE,
MEMBER_TIMED_MUTE, MEMBER_TIMED_MUTE,
MEMBER_TIMED_UNMUTE, MEMBER_TIMED_UNMUTE,
MEMBER_TIMED_BAN,
MEMBER_TIMED_UNBAN,
MEMBER_JOIN_WITH_PRIOR_RECORDS, MEMBER_JOIN_WITH_PRIOR_RECORDS,
OTHER_SPAM_DETECTED, OTHER_SPAM_DETECTED,
MEMBER_ROLE_CHANGES, MEMBER_ROLE_CHANGES,
VOICE_CHANNEL_FORCE_MOVE, VOICE_CHANNEL_FORCE_MOVE,
VOICE_CHANNEL_FORCE_DISCONNECT,
CASE_UPDATE, CASE_UPDATE,

View file

@ -0,0 +1,25 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
@Entity("counters")
export class Counter {
@PrimaryGeneratedColumn()
id: number;
@Column()
guild_id: string;
@Column()
name: string;
@Column()
per_channel: boolean;
@Column()
per_user: boolean;
@Column()
last_decay_at: string;
@Column({ type: "datetime", nullable: true })
delete_at: string | null;
}

View file

@ -0,0 +1,23 @@
import { Column, Entity, PrimaryGeneratedColumn } from "typeorm";
export const TRIGGER_COMPARISON_OPS = ["=", "!=", ">", "<", ">=", "<="] as const;
export type TriggerComparisonOp = typeof TRIGGER_COMPARISON_OPS[number];
@Entity("counter_triggers")
export class CounterTrigger {
@PrimaryGeneratedColumn()
id: number;
@Column()
counter_id: number;
@Column({ type: "varchar" })
comparison_op: TriggerComparisonOp;
@Column()
comparison_value: number;
@Column({ type: "datetime", nullable: true })
delete_at: string | null;
}

View file

@ -0,0 +1,17 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity("counter_trigger_states")
export class CounterTriggerState {
@Column({ type: "bigint", generated: "increment" })
@PrimaryColumn()
id: string;
@Column()
trigger_id: number;
@Column({ type: "bigint" })
channel_id: string;
@Column({ type: "bigint" })
user_id: string;
}

View file

@ -0,0 +1,20 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity("counter_values")
export class CounterValue {
@Column()
@PrimaryColumn()
id: string;
@Column()
counter_id: number;
@Column({ type: "bigint" })
channel_id: string;
@Column({ type: "bigint" })
user_id: string;
@Column()
value: number;
}

View file

@ -15,4 +15,6 @@ export class Mute {
@Column({ type: String, nullable: true }) expires_at: string | null; @Column({ type: String, nullable: true }) expires_at: string | null;
@Column() case_id: number; @Column() case_id: number;
@Column("simple-array") roles_to_restore: string[];
} }

View file

@ -0,0 +1,18 @@
import { Column, Entity, PrimaryColumn } from "typeorm";
@Entity("tempbans")
export class Tempban {
@Column()
@PrimaryColumn()
guild_id: string;
@Column()
@PrimaryColumn()
user_id: string;
@Column() mod_id: string;
@Column() created_at: string;
@Column() expires_at: string;
}

View file

@ -0,0 +1,18 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class CreateRestoredRolesColumn1608608903570 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.addColumn(
"mutes",
new TableColumn({
name: "roles_to_restore",
type: "text",
isNullable: true,
}),
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropColumn("mutes", "roles_to_restore");
}
}

View file

@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner, Table, TableIndex } from "typeorm";
export class CreateTempBansTable1608753440716 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
const table = await queryRunner.createTable(
new Table({
name: "tempbans",
columns: [
{
name: "guild_id",
type: "bigint",
isPrimary: true,
},
{
name: "user_id",
type: "bigint",
isPrimary: true,
},
{
name: "mod_id",
type: "bigint",
},
{
name: "created_at",
type: "datetime",
},
{
name: "expires_at",
type: "datetime",
},
],
}),
);
queryRunner.createIndex(
"tempbans",
new TableIndex({
columnNames: ["expires_at"],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable("tempbans");
}
}

View file

@ -0,0 +1,203 @@
import { MigrationInterface, QueryRunner, Table } from "typeorm";
export class CreateCounterTables1612010765767 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<any> {
await queryRunner.createTable(
new Table({
name: "counters",
columns: [
{
name: "id",
type: "int",
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "guild_id",
type: "bigint",
},
{
name: "name",
type: "varchar",
length: "255",
},
{
name: "per_channel",
type: "boolean",
},
{
name: "per_user",
type: "boolean",
},
{
name: "last_decay_at",
type: "datetime",
},
{
name: "delete_at",
type: "datetime",
isNullable: true,
default: null,
},
],
indices: [
{
columnNames: ["guild_id", "name"],
isUnique: true,
},
{
columnNames: ["delete_at"],
},
],
}),
);
await queryRunner.createTable(
new Table({
name: "counter_values",
columns: [
{
name: "id",
type: "bigint",
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "counter_id",
type: "int",
},
{
name: "channel_id",
type: "bigint",
},
{
name: "user_id",
type: "bigint",
},
{
name: "value",
type: "int",
},
],
indices: [
{
columnNames: ["counter_id", "channel_id", "user_id"],
isUnique: true,
},
],
foreignKeys: [
{
columnNames: ["counter_id"],
referencedTableName: "counters",
referencedColumnNames: ["id"],
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
],
}),
);
await queryRunner.createTable(
new Table({
name: "counter_triggers",
columns: [
{
name: "id",
type: "int",
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "counter_id",
type: "int",
},
{
name: "comparison_op",
type: "varchar",
length: "16",
},
{
name: "comparison_value",
type: "int",
},
{
name: "delete_at",
type: "datetime",
isNullable: true,
default: null,
},
],
indices: [
{
columnNames: ["counter_id", "comparison_op", "comparison_value"],
isUnique: true,
},
{
columnNames: ["delete_at"],
},
],
foreignKeys: [
{
columnNames: ["counter_id"],
referencedTableName: "counters",
referencedColumnNames: ["id"],
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
],
}),
);
await queryRunner.createTable(
new Table({
name: "counter_trigger_states",
columns: [
{
name: "id",
type: "bigint",
isPrimary: true,
isGenerated: true,
generationStrategy: "increment",
},
{
name: "trigger_id",
type: "int",
},
{
name: "channel_id",
type: "bigint",
},
{
name: "user_id",
type: "bigint",
},
],
indices: [
{
columnNames: ["trigger_id", "channel_id", "user_id"],
isUnique: true,
},
],
foreignKeys: [
{
columnNames: ["trigger_id"],
referencedTableName: "counter_triggers",
referencedColumnNames: ["id"],
onDelete: "CASCADE",
onUpdate: "CASCADE",
},
],
}),
);
}
public async down(queryRunner: QueryRunner): Promise<any> {
await queryRunner.dropTable("counter_trigger_states");
await queryRunner.dropTable("counter_triggers");
await queryRunner.dropTable("counter_values");
await queryRunner.dropTable("counters");
}
}

View file

@ -2,7 +2,7 @@
* @file Utility functions that are plugin-instance-specific (i.e. use PluginData) * @file Utility functions that are plugin-instance-specific (i.e. use PluginData)
*/ */
import { Member } from "eris"; import { AdvancedMessageContent, AllowedMentions, GuildTextableChannel, Member, Message, TextableChannel } from "eris";
import { CommandContext, configUtils, ConfigValidationError, GuildPluginData, helpers, PluginOptions } from "knub"; import { CommandContext, configUtils, ConfigValidationError, GuildPluginData, helpers, PluginOptions } from "knub";
import { decodeAndValidateStrict, StrictValidationError, validate } from "./validatorUtils"; import { decodeAndValidateStrict, StrictValidationError, validate } from "./validatorUtils";
import { deepKeyIntersect, errorMessage, successMessage, tDeepPartial, tNullable } from "./utils"; import { deepKeyIntersect, errorMessage, successMessage, tDeepPartial, tNullable } from "./utils";
@ -137,18 +137,48 @@ export function getPluginConfigPreprocessor(
}; };
} }
export function sendSuccessMessage(pluginData: AnyPluginData<any>, channel, body) { export function sendSuccessMessage(
pluginData: AnyPluginData<any>,
channel: TextableChannel,
body: string,
allowedMentions?: AllowedMentions,
): Promise<Message | undefined> {
const emoji = pluginData.fullConfig.success_emoji || undefined; const emoji = pluginData.fullConfig.success_emoji || undefined;
return channel.createMessage(successMessage(body, emoji)).catch(err => { const formattedBody = successMessage(body, emoji);
logger.warn(`Failed to send success message to ${channel.id} (${channel.guild?.id}): ${err.code} ${err.message}`); const content: AdvancedMessageContent = allowedMentions
}); ? { content: formattedBody, allowedMentions }
: { content: formattedBody };
return channel
.createMessage(content) // Force line break
.catch(err => {
const channelInfo = (channel as GuildTextableChannel).guild
? `${channel.id} (${(channel as GuildTextableChannel).guild.id})`
: `${channel.id}`;
logger.warn(`Failed to send success message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined;
});
} }
export function sendErrorMessage(pluginData: AnyPluginData<any>, channel, body) { export function sendErrorMessage(
pluginData: AnyPluginData<any>,
channel: TextableChannel,
body: string,
allowedMentions?: AllowedMentions,
): Promise<Message | undefined> {
const emoji = pluginData.fullConfig.error_emoji || undefined; const emoji = pluginData.fullConfig.error_emoji || undefined;
return channel.createMessage(errorMessage(body, emoji)).catch(err => { const formattedBody = errorMessage(body, emoji);
logger.warn(`Failed to send error message to ${channel.id} (${channel.guild?.id}): ${err.code} ${err.message}`); const content: AdvancedMessageContent = allowedMentions
}); ? { content: formattedBody, allowedMentions }
: { content: formattedBody };
return channel
.createMessage(content) // Force line break
.catch(err => {
const channelInfo = (channel as GuildTextableChannel).guild
? `${channel.id} (${(channel as GuildTextableChannel).guild.id})`
: `${channel.id}`;
logger.warn(`Failed to send error message to ${channelInfo}): ${err.code} ${err.message}`);
return undefined;
});
} }
export function getBaseUrl(pluginData: AnyPluginData<any>) { export function getBaseUrl(pluginData: AnyPluginData<any>) {

View file

@ -28,6 +28,12 @@ import { LogType } from "../../data/LogType";
import { logger } from "../../logger"; import { logger } from "../../logger";
import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners"; import { discardRegExpRunner, getRegExpRunner } from "../../regExpRunners";
import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate"; import { RunAutomodOnMemberUpdate } from "./events/RunAutomodOnMemberUpdate";
import { CountersPlugin } from "../Counters/CountersPlugin";
import { parseCondition } from "../../data/GuildCounters";
import { runAutomodOnCounterTrigger } from "./events/runAutomodOnCounterTrigger";
import { runAutomodOnModAction } from "./events/runAutomodOnModAction";
import { registerEventListenersFromMap } from "../../utils/registerEventListenersFromMap";
import { unregisterEventListenersFromMap } from "../../utils/unregisterEventListenersFromMap";
const defaultOptions = { const defaultOptions = {
config: { config: {
@ -53,7 +59,7 @@ const defaultOptions = {
}; };
/** /**
* Config preprocessor to set default values for triggers * Config preprocessor to set default values for triggers and perform extra validation
*/ */
const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => { const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
if (options.config?.rules) { if (options.config?.rules) {
@ -108,6 +114,15 @@ const configPreprocessor: ConfigPreprocessorFn<AutomodPluginType> = options => {
]); ]);
} }
} }
if (triggerName === "counter") {
const parsedCondition = parseCondition(triggerObj[triggerName]!.condition);
if (parsedCondition == null) {
throw new StrictValidationError([
`Invalid counter condition '${triggerObj[triggerName]!.condition}' in rule <${rule.name}>`,
]);
}
}
} }
} }
} }
@ -151,7 +166,13 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
showInDocs: true, showInDocs: true,
info: pluginInfo, info: pluginInfo,
dependencies: [LogsPlugin, ModActionsPlugin, MutesPlugin], // prettier-ignore
dependencies: [
LogsPlugin,
ModActionsPlugin,
MutesPlugin,
CountersPlugin,
],
configSchema: ConfigSchema, configSchema: ConfigSchema,
defaultOptions, defaultOptions,
@ -161,6 +182,7 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
return criteria?.antiraid_level ? criteria.antiraid_level === pluginData.state.cachedAntiraidLevel : false; return criteria?.antiraid_level ? criteria.antiraid_level === pluginData.state.cachedAntiraidLevel : false;
}, },
// prettier-ignore
events: [ events: [
RunAutomodOnJoinEvt, RunAutomodOnJoinEvt,
RunAutomodOnMemberUpdate, RunAutomodOnMemberUpdate,
@ -204,6 +226,69 @@ export const AutomodPlugin = zeppelinGuildPlugin<AutomodPluginType>()("automod",
pluginData.state.cachedAntiraidLevel = await pluginData.state.antiraidLevels.get(); pluginData.state.cachedAntiraidLevel = await pluginData.state.antiraidLevels.get();
}, },
async onAfterLoad(pluginData) {
const countersPlugin = pluginData.getPlugin(CountersPlugin);
pluginData.state.onCounterTrigger = (name, condition, channelId, userId) => {
runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, false);
};
pluginData.state.onCounterReverseTrigger = (name, condition, channelId, userId) => {
runAutomodOnCounterTrigger(pluginData, name, condition, channelId, userId, true);
};
const config = pluginData.config.get();
for (const rule of Object.values(config.rules)) {
for (const trigger of rule.triggers) {
if (trigger.counter) {
await countersPlugin.initCounterTrigger(trigger.counter.name, trigger.counter.condition);
}
}
}
countersPlugin.onCounterEvent("trigger", pluginData.state.onCounterTrigger);
countersPlugin.onCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger);
const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter();
pluginData.state.modActionsListeners = new Map();
pluginData.state.modActionsListeners.set("note", (userId: string) =>
runAutomodOnModAction(pluginData, "note", userId),
);
pluginData.state.modActionsListeners.set("warn", (userId: string) =>
runAutomodOnModAction(pluginData, "warn", userId),
);
pluginData.state.modActionsListeners.set("kick", (userId: string) =>
runAutomodOnModAction(pluginData, "kick", userId),
);
pluginData.state.modActionsListeners.set("ban", (userId: string) =>
runAutomodOnModAction(pluginData, "ban", userId),
);
pluginData.state.modActionsListeners.set("unban", (userId: string) =>
runAutomodOnModAction(pluginData, "unban", userId),
);
registerEventListenersFromMap(modActionsEvents, pluginData.state.modActionsListeners);
const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter();
pluginData.state.mutesListeners = new Map();
pluginData.state.mutesListeners.set("mute", (userId: string) => runAutomodOnModAction(pluginData, "mute", userId));
pluginData.state.mutesListeners.set("unmute", (userId: string) =>
runAutomodOnModAction(pluginData, "unmute", userId),
);
registerEventListenersFromMap(mutesEvents, pluginData.state.mutesListeners);
},
async onBeforeUnload(pluginData) {
const countersPlugin = pluginData.getPlugin(CountersPlugin);
countersPlugin.offCounterEvent("trigger", pluginData.state.onCounterTrigger);
countersPlugin.offCounterEvent("reverseTrigger", pluginData.state.onCounterReverseTrigger);
const modActionsEvents = pluginData.getPlugin(ModActionsPlugin).getEventEmitter();
unregisterEventListenersFromMap(modActionsEvents, pluginData.state.modActionsListeners);
const mutesEvents = pluginData.getPlugin(MutesPlugin).getEventEmitter();
unregisterEventListenersFromMap(mutesEvents, pluginData.state.mutesListeners);
},
async onUnload(pluginData) { async onUnload(pluginData) {
pluginData.state.queue.clear(); pluginData.state.queue.clear();

View file

@ -37,7 +37,7 @@ export const AlertAction = automodAction({
const safeUser = safeUsers[0]; const safeUser = safeUsers[0];
const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", "); const actionsTaken = Object.keys(pluginData.config.get().rules[ruleName].actions).join(", ");
const logMessage = logs.getLogMessage(LogType.AUTOMOD_ACTION, { const logMessage = await logs.getLogMessage(LogType.AUTOMOD_ACTION, {
rule: ruleName, rule: ruleName,
user: safeUser, user: safeUser,
users: safeUsers, users: safeUsers,

View file

@ -12,6 +12,7 @@ import { AddRolesAction } from "./addRoles";
import { RemoveRolesAction } from "./removeRoles"; import { RemoveRolesAction } from "./removeRoles";
import { SetAntiraidLevelAction } from "./setAntiraidLevel"; import { SetAntiraidLevelAction } from "./setAntiraidLevel";
import { ReplyAction } from "./reply"; import { ReplyAction } from "./reply";
import { ChangeCounterAction } from "./changeCounter";
export const availableActions: Record<string, AutomodActionBlueprint<any>> = { export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
clean: CleanAction, clean: CleanAction,
@ -26,6 +27,7 @@ export const availableActions: Record<string, AutomodActionBlueprint<any>> = {
remove_roles: RemoveRolesAction, remove_roles: RemoveRolesAction,
set_antiraid_level: SetAntiraidLevelAction, set_antiraid_level: SetAntiraidLevelAction,
reply: ReplyAction, reply: ReplyAction,
change_counter: ChangeCounterAction,
}; };
export const AvailableActions = t.type({ export const AvailableActions = t.type({
@ -41,4 +43,5 @@ export const AvailableActions = t.type({
remove_roles: RemoveRolesAction.configType, remove_roles: RemoveRolesAction.configType,
set_antiraid_level: SetAntiraidLevelAction.configType, set_antiraid_level: SetAntiraidLevelAction.configType,
reply: ReplyAction.configType, reply: ReplyAction.configType,
change_counter: ChangeCounterAction.configType,
}); });

View file

@ -0,0 +1,27 @@
import * as t from "io-ts";
import { automodAction } from "../helpers";
import { CountersPlugin } from "../../Counters/CountersPlugin";
export const ChangeCounterAction = automodAction({
configType: t.type({
name: t.string,
change: t.string,
}),
defaultConfig: {},
async apply({ pluginData, contexts, actionConfig, matchResult }) {
const change = parseInt(actionConfig.change, 10);
if (Number.isNaN(change)) {
throw new Error("Invalid change number");
}
const countersPlugin = pluginData.getPlugin(CountersPlugin);
countersPlugin.changeCounterValue(
actionConfig.name,
contexts[0].message?.channel_id || null,
contexts[0].user?.id || null,
change,
);
},
});

View file

@ -22,6 +22,8 @@ export const MuteAction = automodAction({
duration: tNullable(tDelayString), duration: tNullable(tDelayString),
notify: tNullable(t.string), notify: tNullable(t.string),
notifyChannel: tNullable(t.string), notifyChannel: tNullable(t.string),
remove_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
restore_roles_on_mute: tNullable(t.union([t.boolean, t.array(t.string)])),
}), }),
defaultConfig: { defaultConfig: {
@ -32,6 +34,8 @@ export const MuteAction = automodAction({
const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined; const duration = actionConfig.duration ? convertDelayStringToMS(actionConfig.duration)! : undefined;
const reason = actionConfig.reason || "Muted automatically"; const reason = actionConfig.reason || "Muted automatically";
const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined; const contactMethods = actionConfig.notify ? resolveActionContactMethods(pluginData, actionConfig) : undefined;
const rolesToRemove = actionConfig.remove_roles_on_mute;
const rolesToRestore = actionConfig.restore_roles_on_mute;
const caseArgs = { const caseArgs = {
modId: pluginData.client.user.id, modId: pluginData.client.user.id,
@ -43,7 +47,7 @@ export const MuteAction = automodAction({
const mutes = pluginData.getPlugin(MutesPlugin); const mutes = pluginData.getPlugin(MutesPlugin);
for (const userId of userIdsToMute) { for (const userId of userIdsToMute) {
try { try {
await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs }); await mutes.muteUser(userId, duration, reason, { contactMethods, caseArgs }, rolesToRemove, rolesToRestore);
} catch (e) { } catch (e) {
if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) { if (e instanceof RecoverablePluginError && e.code === ERRORS.NO_MUTE_ROLE_IN_CONFIG) {
pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, { pluginData.getPlugin(LogsPlugin).log(LogType.BOT_ALERT, {

View file

@ -66,11 +66,15 @@ export const RemoveRolesAction = automodAction({
return; return;
} }
const memberRolesLock = await pluginData.locks.acquire(`member-roles-${member.id}`);
const rolesArr = Array.from(memberRoles.values()); const rolesArr = Array.from(memberRoles.values());
await member.edit({ await member.edit({
roles: rolesArr, roles: rolesArr,
}); });
member.roles = rolesArr; // Make sure we know of the new roles internally as well member.roles = rolesArr; // Make sure we know of the new roles internally as well
memberRolesLock.unlock();
}), }),
); );
}, },

View file

@ -0,0 +1,34 @@
import { GuildPluginData } from "knub";
import { AutomodContext, AutomodPluginType } from "../types";
import { runAutomod } from "../functions/runAutomod";
import { resolveMember, resolveUser, UnknownUser } from "../../../utils";
export async function runAutomodOnCounterTrigger(
pluginData: GuildPluginData<AutomodPluginType>,
counterName: string,
condition: string,
channelId: string | null,
userId: string | null,
reverse: boolean,
) {
const user = userId ? await resolveUser(pluginData.client, userId) : undefined;
const member = (userId && (await resolveMember(pluginData.client, pluginData.guild, userId))) || undefined;
const context: AutomodContext = {
timestamp: Date.now(),
counterTrigger: {
name: counterName,
condition,
channelId,
userId,
reverse,
},
user: user instanceof UnknownUser ? undefined : user,
member,
};
pluginData.state.queue.add(async () => {
await runAutomod(pluginData, context);
});
}

View file

@ -0,0 +1,27 @@
import { GuildPluginData } from "knub";
import { AutomodContext, AutomodPluginType } from "../types";
import { runAutomod } from "../functions/runAutomod";
import { resolveUser, UnknownUser } from "../../../utils";
import { ModActionType } from "../../ModActions/types";
export async function runAutomodOnModAction(
pluginData: GuildPluginData<AutomodPluginType>,
modAction: ModActionType,
userId: string,
reason?: string,
) {
const user = await resolveUser(pluginData.client, userId);
const context: AutomodContext = {
timestamp: Date.now(),
user: user instanceof UnknownUser ? undefined : user,
modAction: {
type: modAction,
reason,
},
};
pluginData.state.queue.add(async () => {
await runAutomod(pluginData, context);
});
}

View file

@ -24,7 +24,7 @@ export async function runAutomod(pluginData: GuildPluginData<AutomodPluginType>,
for (const [ruleName, rule] of Object.entries(config.rules)) { for (const [ruleName, rule] of Object.entries(config.rules)) {
if (rule.enabled === false) continue; if (rule.enabled === false) continue;
if (!rule.affects_bots && (!user || user.bot)) continue; if (!rule.affects_bots && (!user || user.bot) && !context.counterTrigger) continue;
if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) { if (rule.cooldown && checkAndUpdateCooldown(pluginData, rule, context)) {
return; return;

View file

@ -17,6 +17,14 @@ import { MemberJoinTrigger } from "./memberJoin";
import { RoleAddedTrigger } from "./roleAdded"; import { RoleAddedTrigger } from "./roleAdded";
import { RoleRemovedTrigger } from "./roleRemoved"; import { RoleRemovedTrigger } from "./roleRemoved";
import { StickerSpamTrigger } from "./stickerSpam"; import { StickerSpamTrigger } from "./stickerSpam";
import { CounterTrigger } from "./counter";
import { NoteTrigger } from "./note";
import { WarnTrigger } from "./warn";
import { MuteTrigger } from "./mute";
import { UnmuteTrigger } from "./unmute";
import { KickTrigger } from "./kick";
import { BanTrigger } from "./ban";
import { UnbanTrigger } from "./unban";
export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = { export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>> = {
match_words: MatchWordsTrigger, match_words: MatchWordsTrigger,
@ -37,6 +45,16 @@ export const availableTriggers: Record<string, AutomodTriggerBlueprint<any, any>
character_spam: CharacterSpamTrigger, character_spam: CharacterSpamTrigger,
member_join_spam: MemberJoinSpamTrigger, member_join_spam: MemberJoinSpamTrigger,
sticker_spam: StickerSpamTrigger, sticker_spam: StickerSpamTrigger,
counter: CounterTrigger,
note: NoteTrigger,
warn: WarnTrigger,
mute: MuteTrigger,
unmute: UnmuteTrigger,
kick: KickTrigger,
ban: BanTrigger,
unban: UnbanTrigger,
}; };
export const AvailableTriggers = t.type({ export const AvailableTriggers = t.type({
@ -58,4 +76,14 @@ export const AvailableTriggers = t.type({
character_spam: CharacterSpamTrigger.configType, character_spam: CharacterSpamTrigger.configType,
member_join_spam: MemberJoinSpamTrigger.configType, member_join_spam: MemberJoinSpamTrigger.configType,
sticker_spam: StickerSpamTrigger.configType, sticker_spam: StickerSpamTrigger.configType,
counter: CounterTrigger.configType,
note: NoteTrigger.configType,
warn: WarnTrigger.configType,
mute: MuteTrigger.configType,
unmute: UnmuteTrigger.configType,
kick: KickTrigger.configType,
ban: BanTrigger.configType,
unban: UnbanTrigger.configType,
}); });

View file

@ -0,0 +1,24 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
// tslint:disable-next-line:no-empty-interface
interface BanTriggerResultType {}
export const BanTrigger = automodTrigger<BanTriggerResultType>()({
configType: t.type({}),
defaultConfig: {},
async match({ context }) {
if (context.modAction?.type !== "ban") {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult }) {
return `User was banned`;
},
});

View file

@ -0,0 +1,46 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
import { consumeIgnoredRoleChange } from "../functions/ignoredRoleChanges";
import { CountersPlugin } from "../../Counters/CountersPlugin";
import { tNullable } from "../../../utils";
// tslint:disable-next-line
interface CounterTriggerResult {}
export const CounterTrigger = automodTrigger<CounterTriggerResult>()({
configType: t.type({
name: t.string,
condition: t.string,
reverse: tNullable(t.boolean),
}),
defaultConfig: {},
async match({ triggerConfig, context, pluginData }) {
if (!context.counterTrigger) {
return;
}
if (context.counterTrigger.name !== triggerConfig.name) {
return;
}
if (context.counterTrigger.condition !== triggerConfig.condition) {
return;
}
const reverse = triggerConfig.reverse ?? false;
if (context.counterTrigger.reverse !== reverse) {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult, pluginData, contexts, triggerConfig }) {
// TODO: Show user, channel, reverse
return `Matched counter \`${triggerConfig.name} ${triggerConfig.condition}\``;
},
});

View file

@ -0,0 +1,24 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
// tslint:disable-next-line:no-empty-interface
interface KickTriggerResultType {}
export const KickTrigger = automodTrigger<KickTriggerResultType>()({
configType: t.type({}),
defaultConfig: {},
async match({ context }) {
if (context.modAction?.type !== "kick") {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult }) {
return `User was kicked`;
},
});

View file

@ -0,0 +1,24 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
// tslint:disable-next-line:no-empty-interface
interface MuteTriggerResultType {}
export const MuteTrigger = automodTrigger<MuteTriggerResultType>()({
configType: t.type({}),
defaultConfig: {},
async match({ context }) {
if (context.modAction?.type !== "mute") {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult }) {
return `User was muted`;
},
});

View file

@ -0,0 +1,24 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
// tslint:disable-next-line:no-empty-interface
interface NoteTriggerResultType {}
export const NoteTrigger = automodTrigger<NoteTriggerResultType>()({
configType: t.type({}),
defaultConfig: {},
async match({ context }) {
if (context.modAction?.type !== "note") {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult }) {
return `Note was added on user`;
},
});

View file

@ -0,0 +1,24 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
// tslint:disable-next-line:no-empty-interface
interface UnbanTriggerResultType {}
export const UnbanTrigger = automodTrigger<UnbanTriggerResultType>()({
configType: t.type({}),
defaultConfig: {},
async match({ context }) {
if (context.modAction?.type !== "unban") {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult }) {
return `User was unbanned`;
},
});

View file

@ -0,0 +1,24 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
// tslint:disable-next-line:no-empty-interface
interface UnmuteTriggerResultType {}
export const UnmuteTrigger = automodTrigger<UnmuteTriggerResultType>()({
configType: t.type({}),
defaultConfig: {},
async match({ context }) {
if (context.modAction?.type !== "unmute") {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult }) {
return `User was unmuted`;
},
});

View file

@ -0,0 +1,24 @@
import * as t from "io-ts";
import { automodTrigger } from "../helpers";
// tslint:disable-next-line:no-empty-interface
interface WarnTriggerResultType {}
export const WarnTrigger = automodTrigger<WarnTriggerResultType>()({
configType: t.type({}),
defaultConfig: {},
async match({ context }) {
if (context.modAction?.type !== "warn") {
return;
}
return {
extra: {},
};
},
renderMatchInformation({ matchResult }) {
return `User was warned`;
},
});

View file

@ -13,6 +13,9 @@ import { GuildArchives } from "../../data/GuildArchives";
import { RecentActionType } from "./constants"; import { RecentActionType } from "./constants";
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
import { RegExpRunner } from "../../RegExpRunner"; import { RegExpRunner } from "../../RegExpRunner";
import { CounterEvents } from "../Counters/types";
import { ModActionsEvents, ModActionType } from "../ModActions/types";
import { MutesEvents } from "../Mutes/types";
export const Rule = t.type({ export const Rule = t.type({
enabled: t.boolean, enabled: t.boolean,
@ -86,6 +89,12 @@ export interface AutomodPluginType extends BasePluginType {
onMessageCreateFn: any; onMessageCreateFn: any;
onMessageUpdateFn: any; onMessageUpdateFn: any;
onCounterTrigger: CounterEvents["trigger"];
onCounterReverseTrigger: CounterEvents["reverseTrigger"];
modActionsListeners: Map<keyof ModActionsEvents, any>;
mutesListeners: Map<keyof MutesEvents, any>;
}; };
} }
@ -93,6 +102,13 @@ export interface AutomodContext {
timestamp: number; timestamp: number;
actioned?: boolean; actioned?: boolean;
counterTrigger?: {
name: string;
condition: string;
channelId: string | null;
userId: string | null;
reverse: boolean;
};
user?: User; user?: User;
message?: SavedMessage; message?: SavedMessage;
member?: Member; member?: Member;
@ -101,6 +117,10 @@ export interface AutomodContext {
added?: string[]; added?: string[];
removed?: string[]; removed?: string[];
}; };
modAction?: {
type: ModActionType;
reason?: string;
};
} }
export interface RecentAction { export interface RecentAction {

View file

@ -15,6 +15,8 @@ import { AddDashboardUserCmd } from "./commands/AddDashboardUserCmd";
import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd"; import { RemoveDashboardUserCmd } from "./commands/RemoveDashboardUserCmd";
import { Configs } from "../../data/Configs"; import { Configs } from "../../data/Configs";
import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments"; import { ApiPermissionAssignments } from "../../data/ApiPermissionAssignments";
import { ListDashboardUsersCmd } from "./commands/ListDashboardUsersCmd";
import { ListDashboardPermsCmd } from "./commands/ListDashboardPermsCmd";
const defaultOptions = { const defaultOptions = {
config: { config: {
@ -37,6 +39,8 @@ export const BotControlPlugin = zeppelinGlobalPlugin<BotControlPluginType>()("bo
DisallowServerCmd, DisallowServerCmd,
AddDashboardUserCmd, AddDashboardUserCmd,
RemoveDashboardUserCmd, RemoveDashboardUserCmd,
ListDashboardUsersCmd,
ListDashboardPermsCmd,
], ],
onLoad(pluginData) { onLoad(pluginData) {

View file

@ -2,6 +2,7 @@ import { botControlCmd } from "../types";
import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { commandTypeHelpers as ct } from "../../../commandTypes"; import { commandTypeHelpers as ct } from "../../../commandTypes";
import { isSnowflake } from "../../../utils"; import { isSnowflake } from "../../../utils";
import { ApiPermissions } from "@shared/apiPermissions";
export const AllowServerCmd = botControlCmd({ export const AllowServerCmd = botControlCmd({
trigger: ["allow_server", "allowserver", "add_server", "addserver"], trigger: ["allow_server", "allowserver", "add_server", "addserver"],
@ -12,6 +13,7 @@ export const AllowServerCmd = botControlCmd({
signature: { signature: {
guildId: ct.string(), guildId: ct.string(),
userId: ct.string({ required: false }),
}, },
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
@ -26,8 +28,18 @@ export const AllowServerCmd = botControlCmd({
return; return;
} }
if (args.userId && !isSnowflake(args.userId)) {
sendErrorMessage(pluginData, msg.channel, "Invalid user ID!");
return;
}
await pluginData.state.allowedGuilds.add(args.guildId); await pluginData.state.allowedGuilds.add(args.guildId);
await pluginData.state.configs.saveNewRevision(`guild-${args.guildId}`, "plugins: {}", msg.author.id); await pluginData.state.configs.saveNewRevision(`guild-${args.guildId}`, "plugins: {}", msg.author.id);
if (args.userId) {
await pluginData.state.apiPermissionAssignments.addUser(args.guildId, args.userId, [ApiPermissions.EditConfig]);
}
sendSuccessMessage(pluginData, msg.channel, "Server is now allowed to use Zeppelin!"); sendSuccessMessage(pluginData, msg.channel, "Server is now allowed to use Zeppelin!");
}, },
}); });

View file

@ -0,0 +1,78 @@
import { botControlCmd } from "../types";
import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { createChunkedMessage, resolveUser } from "../../../utils";
import { AllowedGuild } from "../../../data/entities/AllowedGuild";
import { ApiPermissionAssignment } from "../../../data/entities/ApiPermissionAssignment";
export const ListDashboardPermsCmd = botControlCmd({
trigger: ["list_dashboard_permissions", "list_dashboard_perms", "list_dash_permissionss", "list_dash_perms"],
permission: null,
config: {
preFilters: [isOwnerPreFilter],
},
signature: {
guildId: ct.string({ option: true, shortcut: "g" }),
user: ct.resolvedUser({ option: true, shortcut: "u" }),
},
async run({ pluginData, message: msg, args }) {
if (!args.user && !args.guildId) {
sendErrorMessage(pluginData, msg.channel, "Must specify at least guildId, user, or both.");
return;
}
let guild: AllowedGuild | undefined;
if (args.guildId) {
guild = await pluginData.state.allowedGuilds.find(args.guildId);
if (!guild) {
sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin");
return;
}
}
let existingUserAssignment: ApiPermissionAssignment[];
if (args.user) {
existingUserAssignment = await pluginData.state.apiPermissionAssignments.getByUserId(args.user.id);
if (existingUserAssignment.length === 0) {
sendErrorMessage(pluginData, msg.channel, "The user has no assigned permissions.");
return;
}
}
let finalMessage = "";
// If we have user, always display which guilds they have permissions in (or only specified guild permissions)
if (args.user) {
for (const assignment of existingUserAssignment!) {
if (guild != null && assignment.guild_id !== args.guildId) continue;
finalMessage += `The user has the following permissions on server \`${
assignment.guild_id
}\`:\n${assignment.permissions.join("\n")}\n\n`;
}
if (finalMessage === "") {
sendErrorMessage(pluginData, msg.channel, "The user has no assigned permissions on the specified server.");
return;
}
// Else display all users that have permissions on the specified guild
} else if (guild) {
const existingGuildAssignment = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id);
if (existingGuildAssignment.length === 0) {
sendErrorMessage(pluginData, msg.channel, "The server has no assigned permissions.");
return;
}
finalMessage += `The server \`${guild.id}\` has the following assigned permissions:\n`; // Double \n for consistency with AddDashboardUserCmd
for (const assignment of existingGuildAssignment) {
const user = await resolveUser(pluginData.client, assignment.target_id);
finalMessage += `\n**${user.username}#${user.discriminator}**, \`${
assignment.target_id
}\`: ${assignment.permissions.join(", ")}`;
}
}
await sendSuccessMessage(pluginData, msg.channel, finalMessage.trim(), {});
},
});

View file

@ -0,0 +1,38 @@
import { botControlCmd } from "../types";
import { isOwnerPreFilter, sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { ApiPermissions } from "@shared/apiPermissions";
import { resolveUser } from "../../../utils";
export const ListDashboardUsersCmd = botControlCmd({
trigger: ["list_dashboard_users"],
permission: null,
config: {
preFilters: [isOwnerPreFilter],
},
signature: {
guildId: ct.string(),
},
async run({ pluginData, message: msg, args }) {
const guild = await pluginData.state.allowedGuilds.find(args.guildId);
if (!guild) {
sendErrorMessage(pluginData, msg.channel, "Server is not using Zeppelin");
return;
}
const dashboardUsers = await pluginData.state.apiPermissionAssignments.getByGuildId(guild.id);
const users = await Promise.all(dashboardUsers.map(perm => resolveUser(pluginData.client, perm.target_id)));
const userNameList = users.map(
user => `<@!${user.id}> (**${user.username}#${user.discriminator}**, \`${user.id}\`)`,
);
sendSuccessMessage(
pluginData,
msg.channel,
`The following users have dashboard access for **${guild.name}**:\n\n${userNameList}`,
{},
);
},
});

View file

@ -16,6 +16,7 @@ import { humanizeDurationShort } from "../../../humanizeDurationShort";
import { caseAbbreviations } from "../caseAbbreviations"; import { caseAbbreviations } from "../caseAbbreviations";
import { getCaseIcon } from "./getCaseIcon"; import { getCaseIcon } from "./getCaseIcon";
import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin"; import { TimeAndDatePlugin } from "../../TimeAndDate/TimeAndDatePlugin";
import { splitIntoCleanChunks, splitMessageIntoChunks } from "knub/dist/helpers";
const CASE_SUMMARY_REASON_MAX_LENGTH = 300; const CASE_SUMMARY_REASON_MAX_LENGTH = 300;
const INCLUDE_MORE_NOTES_THRESHOLD = 20; const INCLUDE_MORE_NOTES_THRESHOLD = 20;
@ -49,9 +50,8 @@ export async function getCaseSummary(
if (reason.length > CASE_SUMMARY_REASON_MAX_LENGTH) { if (reason.length > CASE_SUMMARY_REASON_MAX_LENGTH) {
const match = reason.slice(CASE_SUMMARY_REASON_MAX_LENGTH, 100).match(/(?:[.,!?\s]|$)/); const match = reason.slice(CASE_SUMMARY_REASON_MAX_LENGTH, 100).match(/(?:[.,!?\s]|$)/);
const nextWhitespaceIndex = match ? CASE_SUMMARY_REASON_MAX_LENGTH + match.index! : CASE_SUMMARY_REASON_MAX_LENGTH; const nextWhitespaceIndex = match ? CASE_SUMMARY_REASON_MAX_LENGTH + match.index! : CASE_SUMMARY_REASON_MAX_LENGTH;
if (nextWhitespaceIndex < reason.length) { const reasonChunks = splitMessageIntoChunks(reason, nextWhitespaceIndex);
reason = reason.slice(0, nextWhitespaceIndex - 1) + "..."; reason = reasonChunks[0] + "...";
}
} }
reason = disableLinkPreviews(reason); reason = disableLinkPreviews(reason);

View file

@ -0,0 +1,149 @@
import { zeppelinGuildPlugin } from "../ZeppelinPluginBlueprint";
import { ConfigSchema, CountersPluginType } from "./types";
import { GuildCounters } from "../../data/GuildCounters";
import { mapToPublicFn } from "../../pluginUtils";
import { changeCounterValue } from "./functions/changeCounterValue";
import { setCounterValue } from "./functions/setCounterValue";
import { convertDelayStringToMS, MINUTES, SECONDS } from "../../utils";
import { EventEmitter } from "events";
import { onCounterEvent } from "./functions/onCounterEvent";
import { offCounterEvent } from "./functions/offCounterEvent";
import { emitCounterEvent } from "./functions/emitCounterEvent";
import { ConfigPreprocessorFn } from "knub/dist/config/configTypes";
import { initCounterTrigger } from "./functions/initCounterTrigger";
import { decayCounter } from "./functions/decayCounter";
import { validateCondition } from "./functions/validateCondition";
import { StrictValidationError } from "../../validatorUtils";
import { PluginOptions } from "knub";
import { ViewCounterCmd } from "./commands/ViewCounterCmd";
import { AddCounterCmd } from "./commands/AddCounterCmd";
import { SetCounterCmd } from "./commands/SetCounterCmd";
const MAX_COUNTERS = 5;
const DECAY_APPLY_INTERVAL = 5 * MINUTES;
const defaultOptions: PluginOptions<CountersPluginType> = {
config: {
counters: {},
can_view: false,
can_edit: false,
},
overrides: [
{
level: ">=50",
config: {
can_view: true,
},
},
{
level: ">=100",
config: {
can_edit: true,
},
},
],
};
const configPreprocessor: ConfigPreprocessorFn<CountersPluginType> = options => {
for (const counter of Object.values(options.config?.counters || {})) {
counter.per_user = counter.per_user ?? false;
counter.per_channel = counter.per_channel ?? false;
counter.initial_value = counter.initial_value ?? 0;
}
if (Object.values(options.config?.counters || {}).length > MAX_COUNTERS) {
throw new StrictValidationError([`You can only have at most ${MAX_COUNTERS} active counters`]);
}
return options;
};
/**
* The Counters plugin keeps track of simple integer values that are tied to a user, channel, both, or neither "counters".
* These values can be changed using the functions in the plugin's public interface.
* These values can also be set to automatically decay over time.
*
* Triggers can be registered that check for a specific condition, e.g. "when this counter is over 100".
* Triggers are checked against every time a counter's value changes, and will emit an event when triggered.
* A single trigger can only trigger once per user/channel/in general, depending on how specific the counter is (e.g. a per-user trigger can only trigger once per user).
* After being triggered, a trigger is "reset" if the counter value no longer matches the trigger (e.g. drops to 100 or below in the above example). After this, that trigger can be triggered again.
*/
export const CountersPlugin = zeppelinGuildPlugin<CountersPluginType>()("counters", {
configSchema: ConfigSchema,
defaultOptions,
configPreprocessor,
public: {
// Change a counter's value by a relative amount, e.g. +5
changeCounterValue: mapToPublicFn(changeCounterValue),
// Set a counter's value to an absolute value
setCounterValue: mapToPublicFn(setCounterValue),
// Initialize a trigger. Once initialized, events will be fired when this trigger is triggered.
initCounterTrigger: mapToPublicFn(initCounterTrigger),
// Validate a trigger's condition string
validateCondition: mapToPublicFn(validateCondition),
onCounterEvent: mapToPublicFn(onCounterEvent),
offCounterEvent: mapToPublicFn(offCounterEvent),
},
// prettier-ignore
commands: [
ViewCounterCmd,
AddCounterCmd,
SetCounterCmd,
],
async onLoad(pluginData) {
pluginData.state.counters = new GuildCounters(pluginData.guild.id);
pluginData.state.events = new EventEmitter();
// Initialize and store the IDs of each of the counters internally
pluginData.state.counterIds = {};
const config = pluginData.config.get();
for (const [counterName, counter] of Object.entries(config.counters)) {
const dbCounter = await pluginData.state.counters.findOrCreateCounter(
counterName,
counter.per_channel,
counter.per_user,
);
pluginData.state.counterIds[counterName] = dbCounter.id;
}
// Mark old/unused counters to be deleted later
await pluginData.state.counters.markUnusedCountersToBeDeleted([...Object.values(pluginData.state.counterIds)]);
// Start decay timers
pluginData.state.decayTimers = [];
for (const [counterName, counter] of Object.entries(config.counters)) {
if (!counter.decay) {
continue;
}
const decay = counter.decay;
const decayPeriodMs = convertDelayStringToMS(decay.every)!;
pluginData.state.decayTimers.push(
setInterval(() => {
decayCounter(pluginData, counterName, decayPeriodMs, decay.amount);
}, DECAY_APPLY_INTERVAL),
);
}
// Initially set the counter trigger map to just an empty map
// The actual triggers are added by other plugins via initCounterTrigger()
pluginData.state.counterTriggersByCounterId = new Map();
// Mark all triggers to be deleted later. This is cancelled/reset when a plugin adds the trigger again via initCounterTrigger().
await pluginData.state.counters.markAllTriggersTobeDeleted();
},
onUnload(pluginData) {
for (const interval of pluginData.state.decayTimers) {
clearInterval(interval);
}
pluginData.state.events.removeAllListeners();
},
});

View file

@ -0,0 +1,141 @@
import { guildCommand } from "knub";
import { CountersPluginType } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { sendErrorMessage } from "../../../pluginUtils";
import { resolveChannel, waitForReply } from "knub/dist/helpers";
import { TextChannel, User } from "eris";
import { resolveUser, UnknownUser } from "../../../utils";
import { changeCounterValue } from "../functions/changeCounterValue";
export const AddCounterCmd = guildCommand<CountersPluginType>()({
trigger: ["counters add", "counter add", "addcounter"],
permission: "can_edit",
signature: [
{
counterName: ct.string(),
amount: ct.number(),
},
{
counterName: ct.string(),
user: ct.resolvedUser(),
amount: ct.number(),
},
{
counterName: ct.string(),
channel: ct.textChannel(),
amount: ct.number(),
},
{
counterName: ct.string(),
channel: ct.textChannel(),
user: ct.resolvedUser(),
amount: ct.number(),
},
{
counterName: ct.string(),
user: ct.resolvedUser(),
channel: ct.textChannel(),
amount: ct.number(),
},
],
async run({ pluginData, message, args }) {
const config = pluginData.config.getForMessage(message);
const counter = config.counters[args.counterName];
const counterId = pluginData.state.counterIds[args.counterName];
if (!counter || !counterId) {
sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`);
return;
}
if (counter.can_edit === false) {
sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`);
return;
}
if (args.channel && !counter.per_channel) {
sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`);
return;
}
if (args.user && !counter.per_user) {
sendErrorMessage(pluginData, message.channel, `This counter is not per-user`);
return;
}
let channel = args.channel;
if (!channel && counter.per_channel) {
message.channel.createMessage(`Which channel's counter value would you like to add to?`);
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialChannel = resolveChannel(pluginData.guild, reply.content);
if (!potentialChannel || !(potentialChannel instanceof TextChannel)) {
sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling");
return;
}
channel = potentialChannel;
}
let user = args.user;
if (!user && counter.per_user) {
message.channel.createMessage(`Which user's counter value would you like to add to?`);
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialUser = await resolveUser(pluginData.client, reply.content);
if (!potentialUser || potentialUser instanceof UnknownUser) {
sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling");
return;
}
user = potentialUser;
}
let amount = args.amount;
if (!amount) {
message.channel.createMessage("How much would you like to add to the counter's value?");
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialAmount = parseInt(reply.content, 10);
if (!potentialAmount) {
sendErrorMessage(pluginData, message.channel, "Not a number, cancelling");
return;
}
amount = potentialAmount;
}
await changeCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, amount);
const newValue = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null);
const counterName = counter.name || args.counterName;
if (channel && user) {
message.channel.createMessage(
`Added ${amount} to **${counterName}** for <@!${user.id}> in <#${channel.id}>. The value is now ${newValue}.`,
);
} else if (channel) {
message.channel.createMessage(
`Added ${amount} to **${counterName}** in <#${channel.id}>. The value is now ${newValue}.`,
);
} else if (user) {
message.channel.createMessage(
`Added ${amount} to **${counterName}** for <@!${user.id}>. The value is now ${newValue}.`,
);
} else {
message.channel.createMessage(`Added ${amount} to **${counterName}**. The value is now ${newValue}.`);
}
},
});

View file

@ -0,0 +1,140 @@
import { guildCommand } from "knub";
import { CountersPluginType } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { sendErrorMessage } from "../../../pluginUtils";
import { resolveChannel, waitForReply } from "knub/dist/helpers";
import { TextChannel, User } from "eris";
import { resolveUser, UnknownUser } from "../../../utils";
import { changeCounterValue } from "../functions/changeCounterValue";
import { setCounterValue } from "../functions/setCounterValue";
export const SetCounterCmd = guildCommand<CountersPluginType>()({
trigger: ["counters set", "counter set", "setcounter"],
permission: "can_edit",
signature: [
{
counterName: ct.string(),
value: ct.number(),
},
{
counterName: ct.string(),
user: ct.resolvedUser(),
value: ct.number(),
},
{
counterName: ct.string(),
channel: ct.textChannel(),
value: ct.number(),
},
{
counterName: ct.string(),
channel: ct.textChannel(),
user: ct.resolvedUser(),
value: ct.number(),
},
{
counterName: ct.string(),
user: ct.resolvedUser(),
channel: ct.textChannel(),
value: ct.number(),
},
],
async run({ pluginData, message, args }) {
const config = pluginData.config.getForMessage(message);
const counter = config.counters[args.counterName];
const counterId = pluginData.state.counterIds[args.counterName];
if (!counter || !counterId) {
sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`);
return;
}
if (counter.can_edit === false) {
sendErrorMessage(pluginData, message.channel, `Missing permissions to edit this counter's value`);
return;
}
if (args.channel && !counter.per_channel) {
sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`);
return;
}
if (args.user && !counter.per_user) {
sendErrorMessage(pluginData, message.channel, `This counter is not per-user`);
return;
}
let channel = args.channel;
if (!channel && counter.per_channel) {
message.channel.createMessage(`Which channel's counter value would you like to add to?`);
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialChannel = resolveChannel(pluginData.guild, reply.content);
if (!potentialChannel || !(potentialChannel instanceof TextChannel)) {
sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling");
return;
}
channel = potentialChannel;
}
let user = args.user;
if (!user && counter.per_user) {
message.channel.createMessage(`Which user's counter value would you like to add to?`);
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialUser = await resolveUser(pluginData.client, reply.content);
if (!potentialUser || potentialUser instanceof UnknownUser) {
sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling");
return;
}
user = potentialUser;
}
let value = args.value;
if (!value) {
message.channel.createMessage("How much would you like to add to the counter's value?");
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialValue = parseInt(reply.content, 10);
if (!potentialValue) {
sendErrorMessage(pluginData, message.channel, "Not a number, cancelling");
return;
}
value = potentialValue;
}
if (value < 0) {
sendErrorMessage(pluginData, message.channel, "Cannot set counter value below 0");
return;
}
await setCounterValue(pluginData, args.counterName, channel?.id ?? null, user?.id ?? null, value);
const counterName = counter.name || args.counterName;
if (channel && user) {
message.channel.createMessage(`Set **${counterName}** for <@!${user.id}> in <#${channel.id}> to ${value}`);
} else if (channel) {
message.channel.createMessage(`Set **${counterName}** in <#${channel.id}> to ${value}`);
} else if (user) {
message.channel.createMessage(`Set **${counterName}** for <@!${user.id}> to ${value}`);
} else {
message.channel.createMessage(`Set **${counterName}** to ${value}`);
}
},
});

View file

@ -0,0 +1,111 @@
import { guildCommand } from "knub";
import { CountersPluginType } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { sendErrorMessage } from "../../../pluginUtils";
import { resolveChannel, waitForReply } from "knub/dist/helpers";
import { TextChannel, User } from "eris";
import { resolveUser, UnknownUser } from "../../../utils";
export const ViewCounterCmd = guildCommand<CountersPluginType>()({
trigger: ["counters view", "counter view", "viewcounter", "counter"],
permission: "can_view",
signature: [
{
counterName: ct.string(),
},
{
counterName: ct.string(),
user: ct.resolvedUser(),
},
{
counterName: ct.string(),
channel: ct.textChannel(),
},
{
counterName: ct.string(),
channel: ct.textChannel(),
user: ct.resolvedUser(),
},
{
counterName: ct.string(),
user: ct.resolvedUser(),
channel: ct.textChannel(),
},
],
async run({ pluginData, message, args }) {
const config = pluginData.config.getForMessage(message);
const counter = config.counters[args.counterName];
const counterId = pluginData.state.counterIds[args.counterName];
if (!counter || !counterId) {
sendErrorMessage(pluginData, message.channel, `Unknown counter: ${args.counterName}`);
return;
}
if (counter.can_view === false) {
sendErrorMessage(pluginData, message.channel, `Missing permissions to view this counter's value`);
return;
}
if (args.channel && !counter.per_channel) {
sendErrorMessage(pluginData, message.channel, `This counter is not per-channel`);
return;
}
if (args.user && !counter.per_user) {
sendErrorMessage(pluginData, message.channel, `This counter is not per-user`);
return;
}
let channel = args.channel;
if (!channel && counter.per_channel) {
message.channel.createMessage(`Which channel's counter value would you like to view?`);
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialChannel = resolveChannel(pluginData.guild, reply.content);
if (!potentialChannel || !(potentialChannel instanceof TextChannel)) {
sendErrorMessage(pluginData, message.channel, "Channel is not a text channel, cancelling");
return;
}
channel = potentialChannel;
}
let user = args.user;
if (!user && counter.per_user) {
message.channel.createMessage(`Which user's counter value would you like to view?`);
const reply = await waitForReply(pluginData.client, message.channel, message.author.id);
if (!reply || !reply.content) {
sendErrorMessage(pluginData, message.channel, "Cancelling");
return;
}
const potentialUser = await resolveUser(pluginData.client, reply.content);
if (!potentialUser || potentialUser instanceof UnknownUser) {
sendErrorMessage(pluginData, message.channel, "Unknown user, cancelling");
return;
}
user = potentialUser;
}
const value = await pluginData.state.counters.getCurrentValue(counterId, channel?.id ?? null, user?.id ?? null);
const finalValue = value ?? counter.initial_value;
const counterName = counter.name || args.counterName;
if (channel && user) {
message.channel.createMessage(`**${counterName}** for <@!${user.id}> in <#${channel.id}> is ${finalValue}`);
} else if (channel) {
message.channel.createMessage(`**${counterName}** in <#${channel.id}> is ${finalValue}`);
} else if (user) {
message.channel.createMessage(`**${counterName}** for <@!${user.id}> is ${finalValue}`);
} else {
message.channel.createMessage(`**${counterName}** is ${finalValue}`);
}
},
});

View file

@ -0,0 +1,48 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { checkCounterTrigger } from "./checkCounterTrigger";
import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger";
export async function changeCounterValue(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
channelId: string | null,
userId: string | null,
change: number,
) {
const config = pluginData.config.get();
const counter = config.counters[counterName];
if (!counter) {
throw new Error(`Unknown counter: ${counterName}`);
}
if (counter.per_channel && !channelId) {
throw new Error(`Counter is per channel but no channel ID was supplied`);
}
if (counter.per_user && !userId) {
throw new Error(`Counter is per user but no user ID was supplied`);
}
channelId = counter.per_channel ? channelId : null;
userId = counter.per_user ? userId : null;
const counterId = pluginData.state.counterIds[counterName];
const lock = await pluginData.locks.acquire(counterId.toString());
await pluginData.state.counters.changeCounterValue(counterId, channelId, userId, change);
// Check for trigger matches, if any, when the counter value changes
const triggers = pluginData.state.counterTriggersByCounterId.get(counterId);
if (triggers) {
const triggersArr = Array.from(triggers.values());
await Promise.all(
triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)),
);
await Promise.all(
triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)),
);
}
lock.unlock();
}

View file

@ -0,0 +1,23 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent";
export async function checkAllValuesForReverseTrigger(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
counterTrigger: CounterTrigger,
) {
const triggeredContexts = await pluginData.state.counters.checkAllValuesForReverseTrigger(counterTrigger);
for (const context of triggeredContexts) {
emitCounterEvent(
pluginData,
"reverseTrigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
context.channelId,
context.userId,
);
}
}

View file

@ -0,0 +1,23 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent";
export async function checkAllValuesForTrigger(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
counterTrigger: CounterTrigger,
) {
const triggeredContexts = await pluginData.state.counters.checkAllValuesForTrigger(counterTrigger);
for (const context of triggeredContexts) {
emitCounterEvent(
pluginData,
"trigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
context.channelId,
context.userId,
);
}
}

View file

@ -0,0 +1,25 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent";
export async function checkCounterTrigger(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
counterTrigger: CounterTrigger,
channelId: string | null,
userId: string | null,
) {
const triggered = await pluginData.state.counters.checkForTrigger(counterTrigger, channelId, userId);
if (triggered) {
await emitCounterEvent(
pluginData,
"trigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
channelId,
userId,
);
}
}

View file

@ -0,0 +1,25 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { buildConditionString } from "../../../data/GuildCounters";
import { CounterTrigger } from "../../../data/entities/CounterTrigger";
import { emitCounterEvent } from "./emitCounterEvent";
export async function checkReverseCounterTrigger(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
counterTrigger: CounterTrigger,
channelId: string | null,
userId: string | null,
) {
const triggered = await pluginData.state.counters.checkForReverseTrigger(counterTrigger, channelId, userId);
if (triggered) {
await emitCounterEvent(
pluginData,
"reverseTrigger",
counterName,
buildConditionString(counterTrigger.comparison_op, counterTrigger.comparison_value),
channelId,
userId,
);
}
}

View file

@ -0,0 +1,32 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { checkAllValuesForTrigger } from "./checkAllValuesForTrigger";
import { checkAllValuesForReverseTrigger } from "./checkAllValuesForReverseTrigger";
export async function decayCounter(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
decayPeriodMS: number,
decayAmount: number,
) {
const config = pluginData.config.get();
const counter = config.counters[counterName];
if (!counter) {
throw new Error(`Unknown counter: ${counterName}`);
}
const counterId = pluginData.state.counterIds[counterName];
const lock = await pluginData.locks.acquire(counterId.toString());
await pluginData.state.counters.decay(counterId, decayPeriodMS, decayAmount);
// Check for trigger matches, if any, when the counter value changes
const triggers = pluginData.state.counterTriggersByCounterId.get(counterId);
if (triggers) {
const triggersArr = Array.from(triggers.values());
await Promise.all(triggersArr.map(trigger => checkAllValuesForTrigger(pluginData, counterName, trigger)));
await Promise.all(triggersArr.map(trigger => checkAllValuesForReverseTrigger(pluginData, counterName, trigger)));
}
lock.unlock();
}

View file

@ -0,0 +1,10 @@
import { CounterEvents, CountersPluginType } from "../types";
import { GuildPluginData } from "knub";
export function emitCounterEvent<TEvent extends keyof CounterEvents>(
pluginData: GuildPluginData<CountersPluginType>,
event: TEvent,
...rest: Parameters<CounterEvents[TEvent]>
) {
return pluginData.state.events.emit(event, ...rest);
}

View file

@ -0,0 +1,31 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { parseCondition } from "../../../data/GuildCounters";
/**
* Initialize a counter trigger.
* After a counter trigger has been initialized, it will be checked against whenever the counter's values change.
* If the trigger is triggered, an event is emitted.
*/
export async function initCounterTrigger(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
condition: string,
) {
const counterId = pluginData.state.counterIds[counterName];
if (!counterId) {
throw new Error(`Unknown counter: ${counterName}`);
}
const parsedComparison = parseCondition(condition);
if (!parsedComparison) {
throw new Error(`Invalid comparison string: ${condition}`);
}
const [comparisonOp, comparisonValue] = parsedComparison;
const counterTrigger = await pluginData.state.counters.initCounterTrigger(counterId, comparisonOp, comparisonValue);
if (!pluginData.state.counterTriggersByCounterId.has(counterId)) {
pluginData.state.counterTriggersByCounterId.set(counterId, new Map());
}
pluginData.state.counterTriggersByCounterId.get(counterId)!.set(counterTrigger.id, counterTrigger);
}

View file

@ -0,0 +1,9 @@
import { CounterEventEmitter, CountersPluginType } from "../types";
import { GuildPluginData } from "knub";
export function offCounterEvent(
pluginData: GuildPluginData<CountersPluginType>,
...rest: Parameters<CounterEventEmitter["off"]>
) {
return pluginData.state.events.off(...rest);
}

View file

@ -0,0 +1,10 @@
import { CounterEvents, CountersPluginType } from "../types";
import { GuildPluginData } from "knub";
export function onCounterEvent<TEvent extends keyof CounterEvents>(
pluginData: GuildPluginData<CountersPluginType>,
event: TEvent,
listener: CounterEvents[TEvent],
) {
return pluginData.state.events.on(event, listener);
}

View file

@ -0,0 +1,45 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { checkCounterTrigger } from "./checkCounterTrigger";
import { checkReverseCounterTrigger } from "./checkReverseCounterTrigger";
export async function setCounterValue(
pluginData: GuildPluginData<CountersPluginType>,
counterName: string,
channelId: string | null,
userId: string | null,
value: number,
) {
const config = pluginData.config.get();
const counter = config.counters[counterName];
if (!counter) {
throw new Error(`Unknown counter: ${counterName}`);
}
if (counter.per_channel && !channelId) {
throw new Error(`Counter is per channel but no channel ID was supplied`);
}
if (counter.per_user && !userId) {
throw new Error(`Counter is per user but no user ID was supplied`);
}
const counterId = pluginData.state.counterIds[counterName];
const lock = await pluginData.locks.acquire(counterId.toString());
await pluginData.state.counters.setCounterValue(counterId, channelId, userId, value);
// Check for trigger matches, if any, when the counter value changes
const triggers = pluginData.state.counterTriggersByCounterId.get(counterId);
if (triggers) {
const triggersArr = Array.from(triggers.values());
await Promise.all(
triggersArr.map(trigger => checkCounterTrigger(pluginData, counterName, trigger, channelId, userId)),
);
await Promise.all(
triggersArr.map(trigger => checkReverseCounterTrigger(pluginData, counterName, trigger, channelId, userId)),
);
}
lock.unlock();
}

View file

@ -0,0 +1,8 @@
import { GuildPluginData } from "knub";
import { CountersPluginType } from "../types";
import { parseCondition } from "../../../data/GuildCounters";
export function validateCondition(pluginData: GuildPluginData<CountersPluginType>, condition: string) {
const parsed = parseCondition(condition);
return parsed != null;
}

View file

@ -0,0 +1,51 @@
import * as t from "io-ts";
import { BasePluginType } from "knub";
import { GuildCounters } from "../../data/GuildCounters";
import { tDelayString, tNullable } from "../../utils";
import { EventEmitter } from "events";
import { CounterTrigger } from "../../data/entities/CounterTrigger";
import Timeout = NodeJS.Timeout;
export const Counter = t.type({
name: tNullable(t.string),
per_channel: t.boolean,
per_user: t.boolean,
initial_value: t.number,
decay: tNullable(
t.type({
amount: t.number,
every: tDelayString,
}),
),
can_view: tNullable(t.boolean),
can_edit: tNullable(t.boolean),
});
export type TCounter = t.TypeOf<typeof Counter>;
export const ConfigSchema = t.type({
counters: t.record(t.string, Counter),
can_view: t.boolean,
can_edit: t.boolean,
});
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface CounterEvents {
trigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void;
reverseTrigger: (name: string, condition: string, channelId: string | null, userId: string | null) => void;
}
export interface CounterEventEmitter extends EventEmitter {
on<U extends keyof CounterEvents>(event: U, listener: CounterEvents[U]): this;
emit<U extends keyof CounterEvents>(event: U, ...args: Parameters<CounterEvents[U]>): boolean;
}
export interface CountersPluginType extends BasePluginType {
config: TConfigSchema;
state: {
counters: GuildCounters;
counterIds: Record<string, number>;
decayTimers: Timeout[];
events: CounterEventEmitter;
counterTriggersByCounterId: Map<number, Map<number, CounterTrigger>>;
};
}

View file

@ -31,7 +31,8 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
timestamp: FORMAT_NO_TIMESTAMP, // Legacy/deprecated, use timestamp_format below instead timestamp: FORMAT_NO_TIMESTAMP, // Legacy/deprecated, use timestamp_format below instead
...DefaultLogMessages, ...DefaultLogMessages,
}, },
ping_user: true, ping_user: true, // Legacy/deprecated, if below is false mentions wont actually ping. In case you really want the old behavior, set below to true
allow_user_mentions: false,
timestamp_format: "YYYY-MM-DD HH:mm:ss z", timestamp_format: "YYYY-MM-DD HH:mm:ss z",
include_embed_timestamp: true, include_embed_timestamp: true,
}, },
@ -40,7 +41,7 @@ const defaultOptions: PluginOptions<LogsPluginType> = {
{ {
level: ">=50", level: ">=50",
config: { config: {
ping_user: false, ping_user: false, // Legacy/deprecated, read comment on global ping_user option
}, },
}, },
], ],

View file

@ -38,7 +38,8 @@ export const ConfigSchema = t.type({
timestamp: t.string, // Legacy/deprecated timestamp: t.string, // Legacy/deprecated
}), }),
]), ]),
ping_user: t.boolean, ping_user: t.boolean, // Legacy/deprecated, if below is false mentions wont actually ping
allow_user_mentions: t.boolean,
timestamp_format: t.string, timestamp_format: t.string,
include_embed_timestamp: t.boolean, include_embed_timestamp: t.boolean,
}); });

View file

@ -61,7 +61,12 @@ export async function getLogMessage(
const memberConfig = pluginData.config.getMatchingConfig({ member, userId: user.id }) || ({} as any); const memberConfig = pluginData.config.getMatchingConfig({ member, userId: user.id }) || ({} as any);
mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user)); // Revert to old behavior (verbose name w/o ping if allow_user_mentions is enabled (for whatever reason))
if (config.allow_user_mentions) {
mentions.push(memberConfig.ping_user ? verboseUserMention(user) : verboseUserName(user));
} else {
mentions.push(verboseUserMention(user));
}
} }
return mentions.join(", "); return mentions.join(", ");

View file

@ -62,7 +62,7 @@ export async function log(pluginData: GuildPluginData<LogsPluginType>, type: Log
type === LogType.CENSOR || type === LogType.CENSOR ||
type === LogType.CLEAN type === LogType.CLEAN
) { ) {
if (data.channel.parent_id && opts.excluded_categories.includes(data.channel.parent_id)) { if (data.channel.parentID && opts.excluded_categories.includes(data.channel.parentID)) {
continue logChannelLoop; continue logChannelLoop;
} }
} }
@ -103,6 +103,7 @@ export async function log(pluginData: GuildPluginData<LogsPluginType>, type: Log
// Default to batched unless explicitly disabled // Default to batched unless explicitly disabled
const batched = opts.batched ?? true; const batched = opts.batched ?? true;
const batchTime = opts.batch_time ?? 1000; const batchTime = opts.batch_time ?? 1000;
const cfg = pluginData.config.get();
if (batched) { if (batched) {
// If we're batching log messages, gather all log messages within the set batch_time into a single message // If we're batching log messages, gather all log messages within the set batch_time into a single message
@ -111,14 +112,14 @@ export async function log(pluginData: GuildPluginData<LogsPluginType>, type: Log
setTimeout(async () => { setTimeout(async () => {
const batchedMessage = pluginData.state.batches.get(channel.id)!.join("\n"); const batchedMessage = pluginData.state.batches.get(channel.id)!.join("\n");
pluginData.state.batches.delete(channel.id); pluginData.state.batches.delete(channel.id);
createChunkedMessage(channel, batchedMessage).catch(noop); createChunkedMessage(channel, batchedMessage, { users: cfg.allow_user_mentions }).catch(noop);
}, batchTime); }, batchTime);
} }
pluginData.state.batches.get(channel.id)!.push(message); pluginData.state.batches.get(channel.id)!.push(message);
} else { } else {
// If we're not batching log messages, just send them immediately // If we're not batching log messages, just send them immediately
await createChunkedMessage(channel, message).catch(noop); await createChunkedMessage(channel, message, { users: cfg.allow_user_mentions }).catch(noop);
} }
} }
} }

View file

@ -17,6 +17,7 @@ import { SoftbanCmd } from "./commands/SoftbanCommand";
import { BanCmd } from "./commands/BanCmd"; import { BanCmd } from "./commands/BanCmd";
import { UnbanCmd } from "./commands/UnbanCmd"; import { UnbanCmd } from "./commands/UnbanCmd";
import { ForcebanCmd } from "./commands/ForcebanCmd"; import { ForcebanCmd } from "./commands/ForcebanCmd";
import { MassunbanCmd } from "./commands/MassUnbanCmd";
import { MassbanCmd } from "./commands/MassBanCmd"; import { MassbanCmd } from "./commands/MassBanCmd";
import { AddCaseCmd } from "./commands/AddCaseCmd"; import { AddCaseCmd } from "./commands/AddCaseCmd";
import { CaseCmd } from "./commands/CaseCmd"; import { CaseCmd } from "./commands/CaseCmd";
@ -36,6 +37,12 @@ import { MassmuteCmd } from "./commands/MassmuteCmd";
import { trimPluginDescription } from "../../utils"; import { trimPluginDescription } from "../../utils";
import { DeleteCaseCmd } from "./commands/DeleteCaseCmd"; import { DeleteCaseCmd } from "./commands/DeleteCaseCmd";
import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin"; import { TimeAndDatePlugin } from "../TimeAndDate/TimeAndDatePlugin";
import { GuildTempbans } from "../../data/GuildTempbans";
import { outdatedTempbansLoop } from "./functions/outdatedTempbansLoop";
import { EventEmitter } from "events";
import { mapToPublicFn } from "../../pluginUtils";
import { onModActionsEvent } from "./functions/onModActionsEvent";
import { offModActionsEvent } from "./functions/offModActionsEvent";
const defaultOptions = { const defaultOptions = {
config: { config: {
@ -49,6 +56,7 @@ const defaultOptions = {
warn_message: "You have received a warning on the {guildName} server: {reason}", warn_message: "You have received a warning on the {guildName} server: {reason}",
kick_message: "You have been kicked from the {guildName} server. Reason given: {reason}", kick_message: "You have been kicked from the {guildName} server. Reason given: {reason}",
ban_message: "You have been banned from the {guildName} server. Reason given: {reason}", ban_message: "You have been banned from the {guildName} server. Reason given: {reason}",
tempban_message: "You have been banned from the {guildName} server for {banTime}. Reason given: {reason}",
alert_on_rejoin: false, alert_on_rejoin: false,
alert_channel: null, alert_channel: null,
warn_notify_enabled: false, warn_notify_enabled: false,
@ -64,6 +72,7 @@ const defaultOptions = {
can_ban: false, can_ban: false,
can_view: false, can_view: false,
can_addcase: false, can_addcase: false,
can_massunban: false,
can_massban: false, can_massban: false,
can_massmute: false, can_massmute: false,
can_hidecase: false, can_hidecase: false,
@ -87,6 +96,7 @@ const defaultOptions = {
{ {
level: ">=100", level: ">=100",
config: { config: {
can_massunban: true,
can_massban: true, can_massban: true,
can_massmute: true, can_massmute: true,
can_hidecase: true, can_hidecase: true,
@ -131,6 +141,7 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()("mod
ForcebanCmd, ForcebanCmd,
MassbanCmd, MassbanCmd,
MassmuteCmd, MassmuteCmd,
MassunbanCmd,
AddCaseCmd, AddCaseCmd,
CaseCmd, CaseCmd,
CasesUserCmd, CasesUserCmd,
@ -158,6 +169,12 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()("mod
banUserId(pluginData, userId, reason, banOptions); banUserId(pluginData, userId, reason, banOptions);
}; };
}, },
on: mapToPublicFn(onModActionsEvent),
off: mapToPublicFn(offModActionsEvent),
getEventEmitter(pluginData) {
return () => pluginData.state.events;
},
}, },
onLoad(pluginData) { onLoad(pluginData) {
@ -165,8 +182,20 @@ export const ModActionsPlugin = zeppelinGuildPlugin<ModActionsPluginType>()("mod
state.mutes = GuildMutes.getGuildInstance(guild.id); state.mutes = GuildMutes.getGuildInstance(guild.id);
state.cases = GuildCases.getGuildInstance(guild.id); state.cases = GuildCases.getGuildInstance(guild.id);
state.tempbans = GuildTempbans.getGuildInstance(guild.id);
state.serverLogs = new GuildLogs(guild.id); state.serverLogs = new GuildLogs(guild.id);
state.unloaded = false;
state.outdatedTempbansTimeout = null;
state.ignoredEvents = []; state.ignoredEvents = [];
state.events = new EventEmitter();
outdatedTempbansLoop(pluginData);
},
onUnload(pluginData) {
pluginData.state.unloaded = true;
pluginData.state.events.removeAllListeners();
}, },
}); });

View file

@ -30,7 +30,8 @@ export const AddCaseCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
// If the user exists as a guild member, make sure we can act on them first // If the user exists as a guild member, make sure we can act on them first

View file

@ -1,14 +1,16 @@
import { modActionsCmd, IgnoredEventType } from "../types"; import { modActionsCmd, IgnoredEventType } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes"; import { commandTypeHelpers as ct } from "../../../commandTypes";
import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils"; import { canActOn, sendErrorMessage, hasPermission, sendSuccessMessage } from "../../../pluginUtils";
import { resolveUser, resolveMember } from "../../../utils"; import { resolveUser, resolveMember, stripObjectToScalars, noop } from "../../../utils";
import { isBanned } from "../functions/isBanned"; import { isBanned } from "../functions/isBanned";
import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs"; import { readContactMethodsFromArgs } from "../functions/readContactMethodsFromArgs";
import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments"; import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
import { banUserId } from "../functions/banUserId"; import { banUserId } from "../functions/banUserId";
import { ignoreEvent } from "../functions/ignoreEvent";
import { LogType } from "../../../data/LogType";
import { getMemberLevel, waitForReaction } from "knub/dist/helpers"; import { getMemberLevel, waitForReaction } from "knub/dist/helpers";
import humanizeDuration from "humanize-duration";
import { CasesPlugin } from "src/plugins/Cases/CasesPlugin";
import { CaseTypes } from "src/data/CaseTypes";
import { LogType } from "src/data/LogType";
const opts = { const opts = {
mod: ct.member({ option: true }), mod: ct.member({ option: true }),
@ -20,9 +22,16 @@ const opts = {
export const BanCmd = modActionsCmd({ export const BanCmd = modActionsCmd({
trigger: "ban", trigger: "ban",
permission: "can_ban", permission: "can_ban",
description: "Ban the specified member", description: "Ban or Tempban the specified member",
signature: [ signature: [
{
user: ct.string(),
time: ct.delay(),
reason: ct.string({ required: false, catchAll: true }),
...opts,
},
{ {
user: ct.string(), user: ct.string(),
reason: ct.string({ required: false, catchAll: true }), reason: ct.string({ required: false, catchAll: true }),
@ -34,26 +43,93 @@ export const BanCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
}
const time = args["time"] ? args["time"] : null;
const reason = formatReasonWithAttachments(args.reason, msg.attachments);
const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id);
// The moderator who did the action is the message author or, if used, the specified -mod
let mod = msg.member;
if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) {
sendErrorMessage(pluginData, msg.channel, "No permission for -mod");
return;
}
mod = args.mod;
} }
const memberToBan = await resolveMember(pluginData.client, pluginData.guild, user.id); // acquire a lock because of the needed user-inputs below (if banned/not on server)
const lock = await pluginData.locks.acquire(`ban-${user.id}`);
let forceban = false; let forceban = false;
const existingTempban = await pluginData.state.tempbans.findExistingTempbanForUserId(user.id);
const banned = await isBanned(pluginData, user.id);
if (!memberToBan) { if (!memberToBan) {
const banned = await isBanned(pluginData, user.id);
if (banned) { if (banned) {
sendErrorMessage(pluginData, msg.channel, `User is already banned`); // Abort if trying to ban user indefinitely if they are already banned indefinitely
return; if (!existingTempban && !time) {
sendErrorMessage(pluginData, msg.channel, `User is already banned indefinitely.`);
return;
}
// Ask the mod if we should update the existing ban
const alreadyBannedMsg = await msg.channel.createMessage("User is already banned, update ban?");
const reply = await waitForReaction(pluginData.client, alreadyBannedMsg, ["✅", "❌"], msg.author.id);
alreadyBannedMsg.delete().catch(noop);
if (!reply || reply.name === "❌") {
sendErrorMessage(pluginData, msg.channel, "User already banned, update cancelled by moderator");
lock.unlock();
return;
} else {
// Update or add new tempban / remove old tempban
if (time && time > 0) {
if (existingTempban) {
pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id);
} else {
pluginData.state.tempbans.addTempban(user.id, time, mod.id);
}
} else if (existingTempban) {
pluginData.state.tempbans.clear(user.id);
}
// Create a new case for the updated ban since we never stored the old case id and log the action
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
modId: mod.id,
type: CaseTypes.Ban,
userId: user.id,
reason,
noteDetails: [`Ban updated to ${time ? humanizeDuration(time) : "indefinite"}`],
});
const logtype = time ? LogType.MEMBER_TIMED_BAN : LogType.MEMBER_BAN;
pluginData.state.serverLogs.log(logtype, {
mod: stripObjectToScalars(mod.user),
user: stripObjectToScalars(user),
caseNumber: createdCase.case_number,
reason,
banTime: time ? humanizeDuration(time) : null,
});
sendSuccessMessage(
pluginData,
msg.channel,
`Ban updated to ${time ? "expire in " + humanizeDuration(time) + " from now" : "indefinite"}`,
);
lock.unlock();
return;
}
} else { } else {
// Ask the mod if we should upgrade to a forceban as the user is not on the server // Ask the mod if we should upgrade to a forceban as the user is not on the server
const notOnServerMsg = await msg.channel.createMessage("User not found on the server, forceban instead?"); const notOnServerMsg = await msg.channel.createMessage("User not found on the server, forceban instead?");
const reply = await waitForReaction(pluginData.client, notOnServerMsg, ["✅", "❌"], msg.author.id); const reply = await waitForReaction(pluginData.client, notOnServerMsg, ["✅", "❌"], msg.author.id);
notOnServerMsg.delete(); notOnServerMsg.delete().catch(noop);
if (!reply || reply.name === "❌") { if (!reply || reply.name === "❌") {
sendErrorMessage(pluginData, msg.channel, "User not on server, ban cancelled by moderator"); sendErrorMessage(pluginData, msg.channel, "User not on server, ban cancelled by moderator");
lock.unlock();
return; return;
} else { } else {
forceban = true; forceban = true;
@ -70,53 +146,62 @@ export const BanCmd = modActionsCmd({
msg.channel, msg.channel,
`Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`, `Cannot ban: target permission level is equal or higher to yours, ${targetLevel} >= ${ourLevel}`,
); );
lock.unlock();
return; return;
} }
// The moderator who did the action is the message author or, if used, the specified -mod
let mod = msg.member;
if (args.mod) {
if (!hasPermission(pluginData, "can_act_as_other", { message: msg, channelId: msg.channel.id })) {
sendErrorMessage(pluginData, msg.channel, "No permission for -mod");
return;
}
mod = args.mod;
}
let contactMethods; let contactMethods;
try { try {
contactMethods = readContactMethodsFromArgs(args); contactMethods = readContactMethodsFromArgs(args);
} catch (e) { } catch (e) {
sendErrorMessage(pluginData, msg.channel, e.message); sendErrorMessage(pluginData, msg.channel, e.message);
lock.unlock();
return; return;
} }
const deleteMessageDays = args["delete-days"] ?? pluginData.config.getForMessage(msg).ban_delete_message_days; const deleteMessageDays = args["delete-days"] ?? pluginData.config.getForMessage(msg).ban_delete_message_days;
const reason = formatReasonWithAttachments(args.reason, msg.attachments); const banResult = await banUserId(
const banResult = await banUserId(pluginData, user.id, reason, { pluginData,
contactMethods, user.id,
caseArgs: { reason,
modId: mod.id, {
ppId: mod.id !== msg.author.id ? msg.author.id : undefined, contactMethods,
caseArgs: {
modId: mod.id,
ppId: mod.id !== msg.author.id ? msg.author.id : undefined,
},
deleteMessageDays,
}, },
deleteMessageDays, time,
}); );
if (banResult.status === "failed") { if (banResult.status === "failed") {
sendErrorMessage(pluginData, msg.channel, `Failed to ban member: ${banResult.error}`); sendErrorMessage(pluginData, msg.channel, `Failed to ban member: ${banResult.error}`);
lock.unlock();
return; return;
} }
let forTime = "";
if (time && time > 0) {
if (existingTempban) {
pluginData.state.tempbans.updateExpiryTime(user.id, time, mod.id);
} else {
pluginData.state.tempbans.addTempban(user.id, time, mod.id);
}
forTime = `for ${humanizeDuration(time)} `;
}
// Confirm the action to the moderator // Confirm the action to the moderator
let response = ""; let response = "";
if (!forceban) { if (!forceban) {
response = `Banned **${user.username}#${user.discriminator}** (Case #${banResult.case.case_number})`; response = `Banned **${user.username}#${user.discriminator}** ${forTime}(Case #${banResult.case.case_number})`;
if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`; if (banResult.notifyResult.text) response += ` (${banResult.notifyResult.text})`;
} else { } else {
response = `Member forcebanned (Case #${banResult.case.case_number})`; response = `Member forcebanned ${forTime}(Case #${banResult.case.case_number})`;
} }
lock.unlock();
sendSuccessMessage(pluginData, msg.channel, response); sendSuccessMessage(pluginData, msg.channel, response);
}, },
}); });

View file

@ -37,7 +37,8 @@ export const CasesUserCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
const cases = await pluginData.state.cases.with("notes").getByUserId(user.id); const cases = await pluginData.state.cases.with("notes").getByUserId(user.id);

View file

@ -32,7 +32,8 @@ export const ForcebanCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
// If the user exists as a guild member, make sure we can act on them first // If the user exists as a guild member, make sure we can act on them first
@ -66,6 +67,7 @@ export const ForcebanCmd = modActionsCmd({
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id); pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_BAN, user.id);
try { try {
// FIXME: Use banUserId()?
await pluginData.guild.banMember(user.id, 1, reason != null ? encodeURIComponent(reason) : undefined); await pluginData.guild.banMember(user.id, 1, reason != null ? encodeURIComponent(reason) : undefined);
} catch (e) { } catch (e) {
sendErrorMessage(pluginData, msg.channel, "Failed to forceban member"); sendErrorMessage(pluginData, msg.channel, "Failed to forceban member");
@ -92,5 +94,7 @@ export const ForcebanCmd = modActionsCmd({
caseNumber: createdCase.case_number, caseNumber: createdCase.case_number,
reason, reason,
}); });
pluginData.state.events.emit("ban", user.id, reason);
}, },
}); });

View file

@ -34,7 +34,8 @@ export const ForcemuteCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id);

View file

@ -32,7 +32,8 @@ export const ForceUnmuteCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
// Check if they're muted in the first place // Check if they're muted in the first place

View file

@ -75,6 +75,8 @@ export const MassbanCmd = modActionsCmd({
reason: `Mass ban: ${banReason}`, reason: `Mass ban: ${banReason}`,
postInCaseLogOverride: false, postInCaseLogOverride: false,
}); });
pluginData.state.events.emit("ban", userId, banReason);
} catch (e) { } catch (e) {
failedBans.push(userId); failedBans.push(userId);
} }

View file

@ -0,0 +1,125 @@
import { modActionsCmd, IgnoredEventType } from "../types";
import { commandTypeHelpers as ct } from "../../../commandTypes";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { stripObjectToScalars } from "../../../utils";
import { isBanned } from "../functions/isBanned";
import { formatReasonWithAttachments } from "../functions/formatReasonWithAttachments";
import { CaseTypes } from "../../../data/CaseTypes";
import { TextChannel } from "eris";
import { waitForReply } from "knub/dist/helpers";
import { ignoreEvent } from "../functions/ignoreEvent";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { LogType } from "../../../data/LogType";
export const MassunbanCmd = modActionsCmd({
trigger: "massunban",
permission: "can_massunban",
description: "Mass-unban a list of user IDs",
signature: [
{
userIds: ct.string({ rest: true }),
},
],
async run({ pluginData, message: msg, args }) {
// Limit to 100 users at once (arbitrary?)
if (args.userIds.length > 100) {
sendErrorMessage(pluginData, msg.channel, `Can only mass-unban max 100 users at once`);
return;
}
// Ask for unban reason (cleaner this way instead of trying to cram it into the args)
msg.channel.createMessage("Unban reason? `cancel` to cancel");
const unbanReasonReply = await waitForReply(pluginData.client, msg.channel as TextChannel, msg.author.id);
if (!unbanReasonReply || !unbanReasonReply.content || unbanReasonReply.content.toLowerCase().trim() === "cancel") {
sendErrorMessage(pluginData, msg.channel, "Cancelled");
return;
}
const unbanReason = formatReasonWithAttachments(unbanReasonReply.content, msg.attachments);
// Ignore automatic unban cases and logs for these users
// We'll create our own cases below and post a single "mass unbanned" log instead
args.userIds.forEach(userId => {
// Use longer timeouts since this can take a while
ignoreEvent(pluginData, IgnoredEventType.Unban, userId, 120 * 1000);
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, userId, 120 * 1000);
});
// Show a loading indicator since this can take a while
const loadingMsg = await msg.channel.createMessage("Unbanning...");
// Unban each user and count failed unbans (if any)
const failedUnbans: Array<{ userId: string; reason: UnbanFailReasons }> = [];
const casesPlugin = pluginData.getPlugin(CasesPlugin);
for (const userId of args.userIds) {
if (!(await isBanned(pluginData, userId))) {
failedUnbans.push({ userId, reason: UnbanFailReasons.NOT_BANNED });
continue;
}
try {
await pluginData.guild.unbanMember(userId, unbanReason != null ? encodeURIComponent(unbanReason) : undefined);
await casesPlugin.createCase({
userId,
modId: msg.author.id,
type: CaseTypes.Unban,
reason: `Mass unban: ${unbanReason}`,
postInCaseLogOverride: false,
});
} catch (e) {
failedUnbans.push({ userId, reason: UnbanFailReasons.UNBAN_FAILED });
}
}
// Clear loading indicator
loadingMsg.delete();
const successfulUnbanCount = args.userIds.length - failedUnbans.length;
if (successfulUnbanCount === 0) {
// All unbans failed - don't create a log entry and notify the user
sendErrorMessage(pluginData, msg.channel, "All unbans failed. Make sure the IDs are valid and banned.");
} else {
// Some or all unbans were successful. Create a log entry for the mass unban and notify the user.
pluginData.state.serverLogs.log(LogType.MASSUNBAN, {
mod: stripObjectToScalars(msg.author),
count: successfulUnbanCount,
reason: unbanReason,
});
if (failedUnbans.length) {
const notBanned = failedUnbans.filter(x => x.reason === UnbanFailReasons.NOT_BANNED);
const unbanFailed = failedUnbans.filter(x => x.reason === UnbanFailReasons.UNBAN_FAILED);
let failedMsg = "";
if (notBanned.length > 0) {
failedMsg += `${notBanned.length}x ${UnbanFailReasons.NOT_BANNED}:`;
notBanned.forEach(fail => {
failedMsg += " " + fail.userId;
});
}
if (unbanFailed.length > 0) {
failedMsg += `\n${unbanFailed.length}x ${UnbanFailReasons.UNBAN_FAILED}:`;
unbanFailed.forEach(fail => {
failedMsg += " " + fail.userId;
});
}
sendSuccessMessage(
pluginData,
msg.channel,
`Unbanned ${successfulUnbanCount} users, ${failedUnbans.length} failed:\n${failedMsg}`,
);
} else {
sendSuccessMessage(pluginData, msg.channel, `Unbanned ${successfulUnbanCount} users successfully`);
}
}
},
});
enum UnbanFailReasons {
NOT_BANNED = "Not banned",
UNBAN_FAILED = "Unban failed",
}

View file

@ -44,7 +44,8 @@ export const MuteCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id); const memberToMute = await resolveMember(pluginData.client, pluginData.guild, user.id);

View file

@ -15,13 +15,19 @@ export const NoteCmd = modActionsCmd({
signature: { signature: {
user: ct.string(), user: ct.string(),
note: ct.string({ catchAll: true }), note: ct.string({ required: false, catchAll: true }),
}, },
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
}
if (!args.note && msg.attachments.length === 0) {
sendErrorMessage(pluginData, msg.channel, "Text or attachment required");
return;
} }
const userName = `${user.username}#${user.discriminator}`; const userName = `${user.username}#${user.discriminator}`;
@ -43,5 +49,7 @@ export const NoteCmd = modActionsCmd({
}); });
sendSuccessMessage(pluginData, msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`); sendSuccessMessage(pluginData, msg.channel, `Note added on **${userName}** (Case #${createdCase.case_number})`);
pluginData.state.events.emit("note", user.id, reason);
}, },
}); });

View file

@ -29,7 +29,8 @@ export const UnbanCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
// The moderator who did the action is the message author or, if used, the specified -mod // The moderator who did the action is the message author or, if used, the specified -mod
@ -63,6 +64,8 @@ export const UnbanCmd = modActionsCmd({
reason, reason,
ppId: mod.id !== msg.author.id ? msg.author.id : undefined, ppId: mod.id !== msg.author.id ? msg.author.id : undefined,
}); });
// Delete the tempban, if one exists
pluginData.state.tempbans.clear(user.id);
// Confirm the action // Confirm the action
sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`); sendSuccessMessage(pluginData, msg.channel, `Member unbanned (Case #${createdCase.case_number})`);

View file

@ -34,7 +34,8 @@ export const UnmuteCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id); const memberToUnmute = await resolveMember(pluginData.client, pluginData.guild, user.id);

View file

@ -30,7 +30,8 @@ export const WarnCmd = modActionsCmd({
async run({ pluginData, message: msg, args }) { async run({ pluginData, message: msg, args }) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id); const memberToWarn = await resolveMember(pluginData.client, pluginData.guild, user.id);
@ -111,5 +112,7 @@ export const WarnCmd = modActionsCmd({
msg.channel, msg.channel,
`Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`, `Warned **${memberToWarn.user.username}#${memberToWarn.user.discriminator}** (Case #${warnResult.case.case_number})${messageResultText}`,
); );
pluginData.state.events.emit("warn", user.id, reason);
}, },
}); });

View file

@ -69,5 +69,7 @@ export const CreateBanCaseOnManualBanEvt = modActionsEvt(
caseNumber: createdCase?.case_number ?? 0, caseNumber: createdCase?.case_number ?? 0,
reason, reason,
}); });
pluginData.state.events.emit("ban", user.id, reason);
}, },
); );

View file

@ -62,6 +62,8 @@ export const CreateKickCaseOnManualKickEvt = modActionsEvt(
mod: mod ? stripObjectToScalars(mod) : null, mod: mod ? stripObjectToScalars(mod) : null,
caseNumber: createdCase?.case_number ?? 0, caseNumber: createdCase?.case_number ?? 0,
}); });
pluginData.state.events.emit("kick", member.id, kickAuditLogEntry.reason || undefined);
} }
}, },
); );

View file

@ -66,5 +66,7 @@ export const CreateUnbanCaseOnManualUnbanEvt = modActionsEvt(
userId: user.id, userId: user.id,
caseNumber: createdCase?.case_number ?? 0, caseNumber: createdCase?.case_number ?? 0,
}); });
pluginData.state.events.emit("unban", user.id);
}, },
); );

View file

@ -25,7 +25,8 @@ export async function actualKickMemberCmd(
) { ) {
const user = await resolveUser(pluginData.client, args.user); const user = await resolveUser(pluginData.client, args.user);
if (!user.id) { if (!user.id) {
return sendErrorMessage(pluginData, msg.channel, `User not found`); sendErrorMessage(pluginData, msg.channel, `User not found`);
return;
} }
const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id); const memberToKick = await resolveMember(pluginData.client, pluginData.guild, user.id);

View file

@ -16,6 +16,7 @@ import { ignoreEvent } from "./ignoreEvent";
import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes"; import { CaseTypes } from "../../../data/CaseTypes";
import { logger } from "../../../logger"; import { logger } from "../../../logger";
import humanizeDuration from "humanize-duration";
/** /**
* Ban the specified user id, whether or not they're actually on the server at the time. Generates a case. * Ban the specified user id, whether or not they're actually on the server at the time. Generates a case.
@ -25,6 +26,7 @@ export async function banUserId(
userId: string, userId: string,
reason?: string, reason?: string,
banOptions: BanOptions = {}, banOptions: BanOptions = {},
banTime?: number,
): Promise<BanResult> { ): Promise<BanResult> {
const config = pluginData.config.get(); const config = pluginData.config.get();
const user = await resolveUser(pluginData.client, userId); const user = await resolveUser(pluginData.client, userId);
@ -43,7 +45,7 @@ export async function banUserId(
: getDefaultContactMethods(pluginData, "ban"); : getDefaultContactMethods(pluginData, "ban");
if (contactMethods.length) { if (contactMethods.length) {
if (config.ban_message) { if (!banTime && config.ban_message) {
const banMessage = await renderTemplate(config.ban_message, { const banMessage = await renderTemplate(config.ban_message, {
guildName: pluginData.guild.name, guildName: pluginData.guild.name,
reason, reason,
@ -52,9 +54,20 @@ export async function banUserId(
: {}, : {},
}); });
notifyResult = await notifyUser(user, banMessage, contactMethods);
} else if (banTime && config.tempban_message) {
const banMessage = await renderTemplate(config.tempban_message, {
guildName: pluginData.guild.name,
reason,
moderator: banOptions.caseArgs?.modId
? stripObjectToScalars(await resolveUser(pluginData.client, banOptions.caseArgs.modId))
: {},
banTime: humanizeDuration(banTime),
});
notifyResult = await notifyUser(user, banMessage, contactMethods); notifyResult = await notifyUser(user, banMessage, contactMethods);
} else { } else {
notifyResult = createUserNotificationError("No ban message specified in config"); notifyResult = createUserNotificationError("No ban/tempban message specified in config");
} }
} }
} }
@ -87,24 +100,35 @@ export async function banUserId(
// Create a case for this action // Create a case for this action
const modId = banOptions.caseArgs?.modId || pluginData.client.user.id; const modId = banOptions.caseArgs?.modId || pluginData.client.user.id;
const casesPlugin = pluginData.getPlugin(CasesPlugin); const casesPlugin = pluginData.getPlugin(CasesPlugin);
const noteDetails: string[] = [];
const timeUntilUnban = banTime ? humanizeDuration(banTime) : "indefinite";
const timeDetails = `Banned ${banTime ? `for ${timeUntilUnban}` : "indefinitely"}`;
if (notifyResult.text) noteDetails.push(ucfirst(notifyResult.text));
noteDetails.push(timeDetails);
const createdCase = await casesPlugin.createCase({ const createdCase = await casesPlugin.createCase({
...(banOptions.caseArgs || {}), ...(banOptions.caseArgs || {}),
userId, userId,
modId, modId,
type: CaseTypes.Ban, type: CaseTypes.Ban,
reason, reason,
noteDetails: notifyResult.text ? [ucfirst(notifyResult.text)] : [], noteDetails,
}); });
// Log the action // Log the action
const mod = await resolveUser(pluginData.client, modId); const mod = await resolveUser(pluginData.client, modId);
pluginData.state.serverLogs.log(LogType.MEMBER_BAN, { const logtype = banTime ? LogType.MEMBER_TIMED_BAN : LogType.MEMBER_BAN;
pluginData.state.serverLogs.log(logtype, {
mod: stripObjectToScalars(mod), mod: stripObjectToScalars(mod),
user: stripObjectToScalars(user), user: stripObjectToScalars(user),
caseNumber: createdCase.case_number, caseNumber: createdCase.case_number,
reason, reason,
banTime: banTime ? humanizeDuration(banTime) : null,
}); });
pluginData.state.events.emit("ban", user.id, reason);
return { return {
status: "success", status: "success",
case: createdCase, case: createdCase,

View file

@ -85,6 +85,8 @@ export async function kickMember(
reason, reason,
}); });
pluginData.state.events.emit("kick", member.id, reason);
return { return {
status: "success", status: "success",
case: createdCase, case: createdCase,

View file

@ -0,0 +1,10 @@
import { GuildPluginData } from "knub";
import { ModActionsEvents, ModActionsPluginType } from "../types";
export function offModActionsEvent<TEvent extends keyof ModActionsEvents>(
pluginData: GuildPluginData<ModActionsPluginType>,
event: TEvent,
listener: ModActionsEvents[TEvent],
) {
return pluginData.state.events.off(event, listener);
}

View file

@ -0,0 +1,10 @@
import { GuildPluginData } from "knub";
import { ModActionsEvents, ModActionsPluginType } from "../types";
export function onModActionsEvent<TEvent extends keyof ModActionsEvents>(
pluginData: GuildPluginData<ModActionsPluginType>,
event: TEvent,
listener: ModActionsEvents[TEvent],
) {
return pluginData.state.events.on(event, listener);
}

View file

@ -0,0 +1,67 @@
import { resolveUser, SECONDS, stripObjectToScalars } from "../../../utils";
import { GuildPluginData } from "knub";
import { IgnoredEventType, ModActionsPluginType } from "../types";
import { LogType } from "src/data/LogType";
import { formatReasonWithAttachments } from "./formatReasonWithAttachments";
import { ignoreEvent } from "./ignoreEvent";
import { isBanned } from "./isBanned";
import { logger } from "src/logger";
import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes";
import moment from "moment-timezone";
import humanizeDuration from "humanize-duration";
const TEMPBAN_LOOP_TIME = 60 * SECONDS;
export async function outdatedTempbansLoop(pluginData: GuildPluginData<ModActionsPluginType>) {
const outdatedTempbans = await pluginData.state.tempbans.getExpiredTempbans();
for (const tempban of outdatedTempbans) {
if (!(await isBanned(pluginData, tempban.user_id))) {
pluginData.state.tempbans.clear(tempban.user_id);
continue;
}
pluginData.state.serverLogs.ignoreLog(LogType.MEMBER_UNBAN, tempban.user_id);
const reason = formatReasonWithAttachments(
`Tempban timed out.
Tempbanned at: \`${tempban.created_at} UTC\``,
[],
);
try {
ignoreEvent(pluginData, IgnoredEventType.Unban, tempban.user_id);
await pluginData.guild.unbanMember(tempban.user_id, reason != null ? encodeURIComponent(reason) : undefined);
} catch (e) {
pluginData.state.serverLogs.log(LogType.BOT_ALERT, {
body: `Encountered an error trying to automatically unban ${tempban.user_id} after tempban timeout`,
});
logger.warn(`Error automatically unbanning ${tempban.user_id} (tempban timeout): ${e}`);
return;
}
// Create case and delete tempban
const casesPlugin = pluginData.getPlugin(CasesPlugin);
const createdCase = await casesPlugin.createCase({
userId: tempban.user_id,
modId: tempban.mod_id,
type: CaseTypes.Unban,
reason,
ppId: undefined,
});
pluginData.state.tempbans.clear(tempban.user_id);
// Log the unban
const banTime = moment(tempban.created_at).diff(moment(tempban.expires_at));
pluginData.state.serverLogs.log(LogType.MEMBER_TIMED_UNBAN, {
mod: stripObjectToScalars(await resolveUser(pluginData.client, tempban.mod_id)),
userId: tempban.user_id,
caseNumber: createdCase.case_number,
reason,
banTime: humanizeDuration(banTime),
});
}
if (!pluginData.state.unloaded) {
pluginData.state.outdatedTempbansTimeout = setTimeout(() => outdatedTempbansLoop(pluginData), TEMPBAN_LOOP_TIME);
}
}

View file

@ -7,6 +7,9 @@ import { GuildLogs } from "../../data/GuildLogs";
import { Case } from "../../data/entities/Case"; import { Case } from "../../data/entities/Case";
import { CaseArgs } from "../Cases/types"; import { CaseArgs } from "../Cases/types";
import { TextChannel } from "eris"; import { TextChannel } from "eris";
import { GuildTempbans } from "../../data/GuildTempbans";
import Timeout = NodeJS.Timeout;
import { EventEmitter } from "events";
export const ConfigSchema = t.type({ export const ConfigSchema = t.type({
dm_on_warn: t.boolean, dm_on_warn: t.boolean,
@ -19,6 +22,7 @@ export const ConfigSchema = t.type({
warn_message: tNullable(t.string), warn_message: tNullable(t.string),
kick_message: tNullable(t.string), kick_message: tNullable(t.string),
ban_message: tNullable(t.string), ban_message: tNullable(t.string),
tempban_message: tNullable(t.string),
alert_on_rejoin: t.boolean, alert_on_rejoin: t.boolean,
alert_channel: tNullable(t.string), alert_channel: tNullable(t.string),
warn_notify_enabled: t.boolean, warn_notify_enabled: t.boolean,
@ -32,6 +36,7 @@ export const ConfigSchema = t.type({
can_ban: t.boolean, can_ban: t.boolean,
can_view: t.boolean, can_view: t.boolean,
can_addcase: t.boolean, can_addcase: t.boolean,
can_massunban: t.boolean,
can_massban: t.boolean, can_massban: t.boolean,
can_massmute: t.boolean, can_massmute: t.boolean,
can_hidecase: t.boolean, can_hidecase: t.boolean,
@ -41,14 +46,33 @@ export const ConfigSchema = t.type({
}); });
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface ModActionsEvents {
note: (userId: string, reason?: string) => void;
warn: (userId: string, reason?: string) => void;
kick: (userId: string, reason?: string) => void;
ban: (userId: string, reason?: string) => void;
unban: (userId: string, reason?: string) => void;
// mute/unmute are in the Mutes plugin
}
export interface ModActionsEventEmitter extends EventEmitter {
on<U extends keyof ModActionsEvents>(event: U, listener: ModActionsEvents[U]): this;
emit<U extends keyof ModActionsEvents>(event: U, ...args: Parameters<ModActionsEvents[U]>): boolean;
}
export interface ModActionsPluginType extends BasePluginType { export interface ModActionsPluginType extends BasePluginType {
config: TConfigSchema; config: TConfigSchema;
state: { state: {
mutes: GuildMutes; mutes: GuildMutes;
cases: GuildCases; cases: GuildCases;
tempbans: GuildTempbans;
serverLogs: GuildLogs; serverLogs: GuildLogs;
unloaded: boolean;
outdatedTempbansTimeout: Timeout | null;
ignoredEvents: IIgnoredEvent[]; ignoredEvents: IIgnoredEvent[];
events: ModActionsEventEmitter;
}; };
} }
@ -115,5 +139,7 @@ export interface BanOptions {
deleteMessageDays?: number; deleteMessageDays?: number;
} }
export type ModActionType = "note" | "warn" | "mute" | "unmute" | "kick" | "ban" | "unban";
export const modActionsCmd = guildCommand<ModActionsPluginType>(); export const modActionsCmd = guildCommand<ModActionsPluginType>();
export const modActionsEvt = guildEventListener<ModActionsPluginType>(); export const modActionsEvt = guildEventListener<ModActionsPluginType>();

View file

@ -13,11 +13,13 @@ import { ClearMutesWithoutRoleCmd } from "./commands/ClearMutesWithoutRoleCmd";
import { ClearMutesCmd } from "./commands/ClearMutesCmd"; import { ClearMutesCmd } from "./commands/ClearMutesCmd";
import { muteUser } from "./functions/muteUser"; import { muteUser } from "./functions/muteUser";
import { unmuteUser } from "./functions/unmuteUser"; import { unmuteUser } from "./functions/unmuteUser";
import { CaseArgs } from "../Cases/types";
import { Member } from "eris"; import { Member } from "eris";
import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt"; import { ClearActiveMuteOnMemberBanEvt } from "./events/ClearActiveMuteOnMemberBanEvt";
import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt"; import { ReapplyActiveMuteOnJoinEvt } from "./events/ReapplyActiveMuteOnJoinEvt";
import { mapToPublicFn } from "../../pluginUtils"; import { mapToPublicFn } from "../../pluginUtils";
import { EventEmitter } from "events";
import { onMutesEvent } from "./functions/onMutesEvent";
import { offMutesEvent } from "./functions/offMutesEvent";
const defaultOptions = { const defaultOptions = {
config: { config: {
@ -32,6 +34,8 @@ const defaultOptions = {
mute_message: "You have been muted on the {guildName} server. Reason given: {reason}", mute_message: "You have been muted on the {guildName} server. Reason given: {reason}",
timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}", timed_mute_message: "You have been muted on the {guildName} server for {time}. Reason given: {reason}",
update_mute_message: "Your mute on the {guildName} server has been updated to {time}.", update_mute_message: "Your mute on the {guildName} server has been updated to {time}.",
remove_roles_on_mute: false,
restore_roles_on_mute: false,
can_view_list: false, can_view_list: false,
can_cleanup: false, can_cleanup: false,
@ -91,6 +95,12 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()("mutes", {
return muteRole ? member.roles.includes(muteRole) : false; return muteRole ? member.roles.includes(muteRole) : false;
}; };
}, },
on: mapToPublicFn(onMutesEvent),
off: mapToPublicFn(offMutesEvent),
getEventEmitter(pluginData) {
return () => pluginData.state.events;
},
}, },
onLoad(pluginData) { onLoad(pluginData) {
@ -99,6 +109,8 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()("mutes", {
pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id); pluginData.state.serverLogs = new GuildLogs(pluginData.guild.id);
pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id); pluginData.state.archives = GuildArchives.getGuildInstance(pluginData.guild.id);
pluginData.state.events = new EventEmitter();
// Check for expired mutes every 5s // Check for expired mutes every 5s
const firstCheckTime = Math.max(Date.now(), FIRST_CHECK_TIME) + FIRST_CHECK_INCREMENT; const firstCheckTime = Math.max(Date.now(), FIRST_CHECK_TIME) + FIRST_CHECK_INCREMENT;
FIRST_CHECK_TIME = firstCheckTime; FIRST_CHECK_TIME = firstCheckTime;
@ -114,5 +126,6 @@ export const MutesPlugin = zeppelinGuildPlugin<MutesPluginType>()("mutes", {
onUnload(pluginData) { onUnload(pluginData) {
clearInterval(pluginData.state.muteClearIntervalId); clearInterval(pluginData.state.muteClearIntervalId);
pluginData.state.events.removeAllListeners();
}, },
}); });

View file

@ -2,6 +2,7 @@ import { GuildPluginData } from "knub";
import { MutesPluginType } from "../types"; import { MutesPluginType } from "../types";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { resolveMember, stripObjectToScalars, UnknownUser } from "../../../utils"; import { resolveMember, stripObjectToScalars, UnknownUser } from "../../../utils";
import { MemberOptions } from "eris";
export async function clearExpiredMutes(pluginData: GuildPluginData<MutesPluginType>) { export async function clearExpiredMutes(pluginData: GuildPluginData<MutesPluginType>) {
const expiredMutes = await pluginData.state.mutes.getExpiredMutes(); const expiredMutes = await pluginData.state.mutes.getExpiredMutes();
@ -14,6 +15,14 @@ export async function clearExpiredMutes(pluginData: GuildPluginData<MutesPluginT
if (muteRole) { if (muteRole) {
await member.removeRole(muteRole); await member.removeRole(muteRole);
} }
if (mute.roles_to_restore) {
const memberOptions: MemberOptions = {};
const guildRoles = pluginData.guild.roles;
memberOptions.roles = Array.from(
new Set([...mute.roles_to_restore, ...member.roles.filter(x => x !== muteRole && guildRoles.has(x))]),
);
member.edit(memberOptions);
}
} catch (e) { } catch (e) {
pluginData.state.serverLogs.log(LogType.BOT_ALERT, { pluginData.state.serverLogs.log(LogType.BOT_ALERT, {
body: `Failed to remove mute role from {userMention(member)}`, body: `Failed to remove mute role from {userMention(member)}`,
@ -29,5 +38,7 @@ export async function clearExpiredMutes(pluginData: GuildPluginData<MutesPluginT
? stripObjectToScalars(member, ["user", "roles"]) ? stripObjectToScalars(member, ["user", "roles"])
: { id: mute.user_id, user: new UnknownUser({ id: mute.user_id }) }, : { id: mute.user_id, user: new UnknownUser({ id: mute.user_id }) },
}); });
pluginData.state.events.emit("unmute", mute.user_id);
} }
} }

View file

@ -12,11 +12,13 @@ import {
UserNotificationMethod, UserNotificationMethod,
} from "../../../utils"; } from "../../../utils";
import { renderTemplate } from "../../../templateFormatter"; import { renderTemplate } from "../../../templateFormatter";
import { TextChannel, User } from "eris"; import { MemberOptions, TextChannel, User } from "eris";
import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes"; import { CaseTypes } from "../../../data/CaseTypes";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { Case } from "../../../data/entities/Case"; import { Case } from "../../../data/entities/Case";
import { sendErrorMessage } from "src/pluginUtils";
import { LogsPlugin } from "src/plugins/Logs/LogsPlugin";
export async function muteUser( export async function muteUser(
pluginData: GuildPluginData<MutesPluginType>, pluginData: GuildPluginData<MutesPluginType>,
@ -24,6 +26,8 @@ export async function muteUser(
muteTime?: number, muteTime?: number,
reason?: string, reason?: string,
muteOptions: MuteOptions = {}, muteOptions: MuteOptions = {},
removeRolesOnMuteOverride: boolean | string[] | null = null,
restoreRolesOnMuteOverride: boolean | string[] | null = null,
) { ) {
const lock = await pluginData.locks.acquire(`mute-${userId}`); const lock = await pluginData.locks.acquire(`mute-${userId}`);
@ -50,10 +54,69 @@ export async function muteUser(
const member = await resolveMember(pluginData.client, pluginData.guild, user.id, true); // Grab the fresh member so we don't have stale role info const member = await resolveMember(pluginData.client, pluginData.guild, user.id, true); // Grab the fresh member so we don't have stale role info
const config = pluginData.config.getMatchingConfig({ member, userId }); const config = pluginData.config.getMatchingConfig({ member, userId });
let rolesToRestore: string[] = [];
if (member) { if (member) {
const logs = pluginData.getPlugin(LogsPlugin);
// remove and store any roles to be removed/restored
const currentUserRoles = member.roles;
const memberOptions: MemberOptions = {};
const removeRoles = removeRolesOnMuteOverride ?? config.remove_roles_on_mute;
const restoreRoles = restoreRolesOnMuteOverride ?? config.restore_roles_on_mute;
// remove roles
if (!Array.isArray(removeRoles)) {
if (removeRoles) {
// exclude managed roles from being removed
const managedRoles = pluginData.guild.roles.filter(x => x.managed).map(y => y.id);
memberOptions.roles = managedRoles.filter(x => member.roles.includes(x));
await member.edit(memberOptions);
}
} else {
memberOptions.roles = currentUserRoles.filter(x => !(<string[]>removeRoles).includes(x));
await member.edit(memberOptions);
}
// set roles to be restored
if (!Array.isArray(restoreRoles)) {
if (restoreRoles) {
rolesToRestore = currentUserRoles;
}
} else {
rolesToRestore = currentUserRoles.filter(x => (<string[]>restoreRoles).includes(x));
}
// Apply mute role if it's missing // Apply mute role if it's missing
if (!member.roles.includes(muteRole)) { if (!member.roles.includes(muteRole)) {
await member.addRole(muteRole); try {
await member.addRole(muteRole);
} catch (e) {
const actualMuteRole = pluginData.guild.roles.find(x => x.id === muteRole);
if (!actualMuteRole) {
lock.unlock();
logs.log(LogType.BOT_ALERT, {
body: `Cannot mute users, specified mute role Id is invalid`,
});
throw new RecoverablePluginError(ERRORS.INVALID_MUTE_ROLE_ID);
}
const zep = await resolveMember(pluginData.client, pluginData.guild, pluginData.client.user.id);
const zepRoles = pluginData.guild.roles.filter(x => zep!.roles.includes(x.id));
// If we have roles and one of them is above the muted role, throw generic error
if (zepRoles.length >= 0 && zepRoles.some(zepRole => zepRole.position > actualMuteRole.position)) {
lock.unlock();
logs.log(LogType.BOT_ALERT, {
body: `Cannot mute user ${member.id}: ${e}`,
});
throw e;
} else {
// Otherwise, throw error that mute role is above zeps roles
lock.unlock();
logs.log(LogType.BOT_ALERT, {
body: `Cannot mute users, specified mute role is above Zeppelin in the role hierarchy`,
});
throw new RecoverablePluginError(ERRORS.MUTE_ROLE_ABOVE_ZEP);
}
}
} }
// If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role) // If enabled, move the user to the mute voice channel (e.g. afk - just to apply the voice perms from the mute role)
@ -71,9 +134,12 @@ export async function muteUser(
let notifyResult: UserNotificationResult = { method: null, success: true }; let notifyResult: UserNotificationResult = { method: null, success: true };
if (existingMute) { if (existingMute) {
await pluginData.state.mutes.updateExpiryTime(user.id, muteTime); if (existingMute.roles_to_restore?.length || rolesToRestore?.length) {
rolesToRestore = Array.from(new Set([...existingMute.roles_to_restore, ...rolesToRestore]));
}
await pluginData.state.mutes.updateExpiryTime(user.id, muteTime, rolesToRestore);
} else { } else {
await pluginData.state.mutes.addMute(user.id, muteTime); await pluginData.state.mutes.addMute(user.id, muteTime, rolesToRestore);
} }
const template = existingMute const template = existingMute
@ -180,6 +246,8 @@ export async function muteUser(
lock.unlock(); lock.unlock();
pluginData.state.events.emit("mute", user.id, reason);
return { return {
case: theCase, case: theCase,
notifyResult, notifyResult,

View file

@ -0,0 +1,10 @@
import { GuildPluginData } from "knub";
import { MutesEvents, MutesPluginType } from "../types";
export function offMutesEvent<TEvent extends keyof MutesEvents>(
pluginData: GuildPluginData<MutesPluginType>,
event: TEvent,
listener: MutesEvents[TEvent],
) {
return pluginData.state.events.off(event, listener);
}

View file

@ -0,0 +1,10 @@
import { GuildPluginData } from "knub";
import { MutesEvents, MutesPluginType } from "../types";
export function onMutesEvent<TEvent extends keyof MutesEvents>(
pluginData: GuildPluginData<MutesPluginType>,
event: TEvent,
listener: MutesEvents[TEvent],
) {
return pluginData.state.events.on(event, listener);
}

View file

@ -7,7 +7,7 @@ import humanizeDuration from "humanize-duration";
import { CasesPlugin } from "../../Cases/CasesPlugin"; import { CasesPlugin } from "../../Cases/CasesPlugin";
import { CaseTypes } from "../../../data/CaseTypes"; import { CaseTypes } from "../../../data/CaseTypes";
import { LogType } from "../../../data/LogType"; import { LogType } from "../../../data/LogType";
import { WithRequiredProps } from "../../../utils/typeUtils"; import { MemberOptions } from "eris";
export async function unmuteUser( export async function unmuteUser(
pluginData: GuildPluginData<MutesPluginType>, pluginData: GuildPluginData<MutesPluginType>,
@ -36,7 +36,16 @@ export async function unmuteUser(
if (muteRole && member.roles.includes(muteRole)) { if (muteRole && member.roles.includes(muteRole)) {
await member.removeRole(muteRole); await member.removeRole(muteRole);
} }
if (existingMute?.roles_to_restore) {
const memberOptions: MemberOptions = {};
const guildRoles = pluginData.guild.roles;
memberOptions.roles = Array.from(
new Set([...existingMute.roles_to_restore, ...member.roles.filter(x => x !== muteRole && guildRoles.has(x))]),
);
member.edit(memberOptions);
}
} else { } else {
// tslint:disable-next-line:no-console
console.warn( console.warn(
`Member ${userId} not found in guild ${pluginData.guild.name} (${pluginData.guild.id}) when attempting to unmute`, `Member ${userId} not found in guild ${pluginData.guild.name} (${pluginData.guild.id}) when attempting to unmute`,
); );
@ -87,6 +96,12 @@ export async function unmuteUser(
}); });
} }
if (!unmuteTime) {
// If the member was unmuted, not just scheduled to be unmuted, fire the unmute event as well
// Scheduled unmutes have their event fired in clearExpiredMutes()
pluginData.state.events.emit("unmute", user.id, caseArgs.reason);
}
return { return {
case: createdCase, case: createdCase,
}; };

View file

@ -10,6 +10,7 @@ import { GuildArchives } from "../../data/GuildArchives";
import { GuildMutes } from "../../data/GuildMutes"; import { GuildMutes } from "../../data/GuildMutes";
import { CaseArgs } from "../Cases/types"; import { CaseArgs } from "../Cases/types";
import Timeout = NodeJS.Timeout; import Timeout = NodeJS.Timeout;
import { EventEmitter } from "events";
export const ConfigSchema = t.type({ export const ConfigSchema = t.type({
mute_role: tNullable(t.string), mute_role: tNullable(t.string),
@ -23,12 +24,24 @@ export const ConfigSchema = t.type({
mute_message: tNullable(t.string), mute_message: tNullable(t.string),
timed_mute_message: tNullable(t.string), timed_mute_message: tNullable(t.string),
update_mute_message: tNullable(t.string), update_mute_message: tNullable(t.string),
remove_roles_on_mute: t.union([t.boolean, t.array(t.string)]),
restore_roles_on_mute: t.union([t.boolean, t.array(t.string)]),
can_view_list: t.boolean, can_view_list: t.boolean,
can_cleanup: t.boolean, can_cleanup: t.boolean,
}); });
export type TConfigSchema = t.TypeOf<typeof ConfigSchema>; export type TConfigSchema = t.TypeOf<typeof ConfigSchema>;
export interface MutesEvents {
mute: (userId: string, reason?: string) => void;
unmute: (userId: string, reason?: string) => void;
}
export interface MutesEventEmitter extends EventEmitter {
on<U extends keyof MutesEvents>(event: U, listener: MutesEvents[U]): this;
emit<U extends keyof MutesEvents>(event: U, ...args: Parameters<MutesEvents[U]>): boolean;
}
export interface MutesPluginType extends BasePluginType { export interface MutesPluginType extends BasePluginType {
config: TConfigSchema; config: TConfigSchema;
state: { state: {
@ -38,6 +51,8 @@ export interface MutesPluginType extends BasePluginType {
archives: GuildArchives; archives: GuildArchives;
muteClearIntervalId: Timeout; muteClearIntervalId: Timeout;
events: MutesEventEmitter;
}; };
} }

View file

@ -20,7 +20,8 @@ export const NamesCmd = nameHistoryCmd({
const usernames = await pluginData.state.usernameHistory.getByUserId(args.userId); const usernames = await pluginData.state.usernameHistory.getByUserId(args.userId);
if (nicknames.length === 0 && usernames.length === 0) { if (nicknames.length === 0 && usernames.length === 0) {
return sendErrorMessage(pluginData, msg.channel, "No name history found"); sendErrorMessage(pluginData, msg.channel, "No name history found");
return;
} }
const nicknameRows = nicknames.map( const nicknameRows = nicknames.map(

View file

@ -16,7 +16,8 @@ export const ScheduledPostsDeleteCmd = postCmd({
scheduledPosts.sort(sorter("post_at")); scheduledPosts.sort(sorter("post_at"));
const post = scheduledPosts[args.num - 1]; const post = scheduledPosts[args.num - 1];
if (!post) { if (!post) {
return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); sendErrorMessage(pluginData, msg.channel, "Scheduled post not found");
return;
} }
await pluginData.state.scheduledPosts.delete(post.id); await pluginData.state.scheduledPosts.delete(post.id);

View file

@ -18,7 +18,8 @@ export const ScheduledPostsShowCmd = postCmd({
scheduledPosts.sort(sorter("post_at")); scheduledPosts.sort(sorter("post_at"));
const post = scheduledPosts[args.num - 1]; const post = scheduledPosts[args.num - 1];
if (!post) { if (!post) {
return sendErrorMessage(pluginData, msg.channel, "Scheduled post not found"); sendErrorMessage(pluginData, msg.channel, "Scheduled post not found");
return;
} }
postMessage(pluginData, msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions); postMessage(pluginData, msg.channel as TextChannel, post.content, post.attachments, post.enable_mentions);

View file

@ -39,14 +39,12 @@ export async function actualPostCmd(
if (opts.repeat) { if (opts.repeat) {
if (opts.repeat < MIN_REPEAT_TIME) { if (opts.repeat < MIN_REPEAT_TIME) {
return sendErrorMessage( sendErrorMessage(pluginData, msg.channel, `Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`);
pluginData, return;
msg.channel,
`Minimum time for -repeat is ${humanizeDuration(MIN_REPEAT_TIME)}`,
);
} }
if (opts.repeat > MAX_REPEAT_TIME) { if (opts.repeat > MAX_REPEAT_TIME) {
return sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`); sendErrorMessage(pluginData, msg.channel, `Max time for -repeat is ${humanizeDuration(MAX_REPEAT_TIME)}`);
return;
} }
} }
@ -56,7 +54,8 @@ export async function actualPostCmd(
// Schedule the post to be posted later // Schedule the post to be posted later
postAt = await parseScheduleTime(pluginData, msg.author.id, opts.schedule); postAt = await parseScheduleTime(pluginData, msg.author.id, opts.schedule);
if (!postAt) { if (!postAt) {
return sendErrorMessage(pluginData, msg.channel, "Invalid schedule time"); sendErrorMessage(pluginData, msg.channel, "Invalid schedule time");
return;
} }
} else if (opts.repeat) { } else if (opts.repeat) {
postAt = moment.utc().add(opts.repeat, "ms"); postAt = moment.utc().add(opts.repeat, "ms");
@ -72,35 +71,37 @@ export async function actualPostCmd(
// Invalid time // Invalid time
if (!repeatUntil) { if (!repeatUntil) {
return sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until"); sendErrorMessage(pluginData, msg.channel, "Invalid time specified for -repeat-until");
return;
} }
if (repeatUntil.isBefore(moment.utc())) { if (repeatUntil.isBefore(moment.utc())) {
return sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past"); sendErrorMessage(pluginData, msg.channel, "You can't set -repeat-until in the past");
return;
} }
if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) { if (repeatUntil.isAfter(MAX_REPEAT_UNTIL)) {
return sendErrorMessage( sendErrorMessage(
pluginData, pluginData,
msg.channel, msg.channel,
"Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?", "Unfortunately, -repeat-until can only be at most 100 years into the future. Maybe 99 years would be enough?",
); );
return;
} }
} else if (opts["repeat-times"]) { } else if (opts["repeat-times"]) {
repeatTimes = opts["repeat-times"]; repeatTimes = opts["repeat-times"];
if (repeatTimes <= 0) { if (repeatTimes <= 0) {
return sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more"); sendErrorMessage(pluginData, msg.channel, "-repeat-times must be 1 or more");
return;
} }
} }
if (repeatUntil && repeatTimes) { if (repeatUntil && repeatTimes) {
return sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once"); sendErrorMessage(pluginData, msg.channel, "You can only use one of -repeat-until or -repeat-times at once");
return;
} }
if (opts.repeat && !repeatUntil && !repeatTimes) { if (opts.repeat && !repeatUntil && !repeatTimes) {
return sendErrorMessage( sendErrorMessage(pluginData, msg.channel, "You must specify -repeat-until or -repeat-times for repeated messages");
pluginData, return;
msg.channel,
"You must specify -repeat-until or -repeat-times for repeated messages",
);
} }
if (opts.repeat) { if (opts.repeat) {
@ -114,7 +115,8 @@ export async function actualPostCmd(
// Save schedule/repeat information in DB // Save schedule/repeat information in DB
if (postAt) { if (postAt) {
if (postAt < moment.utc()) { if (postAt < moment.utc()) {
return sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past"); sendErrorMessage(pluginData, msg.channel, "Post can't be scheduled to be posted in the past");
return;
} }
await pluginData.state.scheduledPosts.create({ await pluginData.state.scheduledPosts.create({

View file

@ -3,7 +3,7 @@ import { commandTypeHelpers as ct } from "../../../commandTypes";
import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils"; import { sendErrorMessage, sendSuccessMessage } from "../../../pluginUtils";
import { TextChannel } from "eris"; import { TextChannel } from "eris";
import { RecoverablePluginError, ERRORS } from "../../../RecoverablePluginError"; import { RecoverablePluginError, ERRORS } from "../../../RecoverablePluginError";
import { canUseEmoji, isDiscordRESTError, isValidEmoji, noop } from "../../../utils"; import { canUseEmoji, isDiscordRESTError, isValidEmoji, noop, trimPluginDescription } from "../../../utils";
import { applyReactionRoleReactionsToMessage } from "../util/applyReactionRoleReactionsToMessage"; import { applyReactionRoleReactionsToMessage } from "../util/applyReactionRoleReactionsToMessage";
import { canReadChannel } from "../../../utils/canReadChannel"; import { canReadChannel } from "../../../utils/canReadChannel";
@ -12,6 +12,19 @@ const CLEAR_ROLES_EMOJI = "❌";
export const InitReactionRolesCmd = reactionRolesCmd({ export const InitReactionRolesCmd = reactionRolesCmd({
trigger: "reaction_roles", trigger: "reaction_roles",
permission: "can_manage", permission: "can_manage",
description: trimPluginDescription(`
This command allows you to add reaction roles to a given message.
The basic usage is as follows:
!reaction_roles 800865377520582687
👍 = 556110793058287637
👎 = 558037973581430785
A reactionRolePair is any emoji the bot can use, an equal sign and the role id it should correspond to.
Every pair needs to be in its own line for the command to work properly.
If the message you specify is not found, use \`!save_messages_to_db <channelId> <messageId>\`
to manually add it to the stored messages database permanently.
`),
signature: { signature: {
message: ct.messageTarget(), message: ct.messageTarget(),

View file

@ -17,17 +17,20 @@ export const AddRoleCmd = rolesCmd({
async run({ message: msg, args, pluginData }) { async run({ message: msg, args, pluginData }) {
if (!canActOn(pluginData, msg.member, args.member, true)) { if (!canActOn(pluginData, msg.member, args.member, true)) {
return sendErrorMessage(pluginData, msg.channel, "Cannot add roles to this user: insufficient permissions"); sendErrorMessage(pluginData, msg.channel, "Cannot add roles to this user: insufficient permissions");
return;
} }
const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role); const roleId = await resolveRoleId(pluginData.client, pluginData.guild.id, args.role);
if (!roleId) { if (!roleId) {
return sendErrorMessage(pluginData, msg.channel, "Invalid role id"); sendErrorMessage(pluginData, msg.channel, "Invalid role id");
return;
} }
const config = pluginData.config.getForMessage(msg); const config = pluginData.config.getForMessage(msg);
if (!config.assignable_roles.includes(roleId)) { if (!config.assignable_roles.includes(roleId)) {
return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); sendErrorMessage(pluginData, msg.channel, "You cannot assign that role");
return;
} }
// Sanity check: make sure the role is configured properly // Sanity check: make sure the role is configured properly
@ -36,11 +39,13 @@ export const AddRoleCmd = rolesCmd({
pluginData.state.logs.log(LogType.BOT_ALERT, { pluginData.state.logs.log(LogType.BOT_ALERT, {
body: `Unknown role configured for 'roles' plugin: ${roleId}`, body: `Unknown role configured for 'roles' plugin: ${roleId}`,
}); });
return sendErrorMessage(pluginData, msg.channel, "You cannot assign that role"); sendErrorMessage(pluginData, msg.channel, "You cannot assign that role");
return;
} }
if (args.member.roles.includes(roleId)) { if (args.member.roles.includes(roleId)) {
return sendErrorMessage(pluginData, msg.channel, "Member already has that role"); sendErrorMessage(pluginData, msg.channel, "Member already has that role");
return;
} }
pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id); pluginData.state.logs.ignoreLog(LogType.MEMBER_ROLE_ADD, args.member.id);

Some files were not shown because too many files have changed in this diff Show more