mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 16:08:02 -05:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
318a1d3326 | ||
|
|
08db50426b | ||
|
|
77f7a0167c | ||
|
|
23a9f9d652 | ||
|
|
72ed72ce4a | ||
|
|
8cea19c7f2 | ||
|
|
8eeaac2d53 | ||
|
|
3cf838ba9f | ||
|
|
16f3c2268b | ||
|
|
3be20b910d | ||
|
|
78aed4321a | ||
|
|
0fe2fa8934 | ||
|
|
37ba1dc1bf | ||
|
|
5905c910b0 | ||
|
|
d239d3c6cc | ||
|
|
16d0eebac6 | ||
|
|
1a393944c0 | ||
|
|
9f270010b7 | ||
|
|
2548cff367 | ||
|
|
c7acda46a0 | ||
|
|
530675179b | ||
|
|
7960423678 | ||
|
|
4ddb0f0963 | ||
|
|
8a54ce15cd | ||
|
|
01161c3493 | ||
|
|
9970156a3d | ||
|
|
b437156d99 | ||
|
|
de3a279dc3 | ||
|
|
86a6a75119 | ||
|
|
9634b59b3a | ||
|
|
37f7c99155 | ||
|
|
a99ab9a64a |
@@ -24,4 +24,4 @@ RUN mkdir -p $log_dir
|
||||
VOLUME $log_dir
|
||||
ENV LOG_DIR=$log_dir
|
||||
|
||||
CMD [ "node", "src/index.js" ]
|
||||
CMD [ "node", "src/index.js", "run" ]
|
||||
|
||||
43
README.md
43
README.md
@@ -23,7 +23,8 @@ Some feature highlights:
|
||||
* All text-based actions support [mustache](https://mustache.github.io) templating
|
||||
* History-based rules support multiple "valid window" types -- [ISO 8601 Durations](https://en.wikipedia.org/wiki/ISO_8601#Durations), [Day.js Durations](https://day.js.org/docs/en/durations/creating), and submission/comment count limits.
|
||||
* All rules support skipping behavior based on author criteria -- name, css flair/text, and moderator status
|
||||
* Docker container support *(coming soon...)*
|
||||
* Rules and Actions support named references so you write rules/actions once and reference them anywhere
|
||||
* Docker container support
|
||||
|
||||
# Table of Contents
|
||||
|
||||
@@ -78,6 +79,10 @@ Adding [**environmental variables**](#usage) to your `docker run` command will p
|
||||
docker run -e "CLIENT_ID=myId" ... foxxmd/reddit-context-bot
|
||||
```
|
||||
|
||||
### [Heroku Quick Deploy](https://heroku.com/about)
|
||||
[](https://dashboard.heroku.com/new?template=https://github.com/FoxxMD/reddit-context-bot)
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
Context Bot's [configuration schema](/src/Schema/App.json) conforms to [JSON Schema](https://json-schema.org/) Draft 7.
|
||||
@@ -118,7 +123,7 @@ The properties of `rules` are accessible using the name, lower-cased, with all s
|
||||
},
|
||||
{
|
||||
// name = repeatsubmission
|
||||
"kind": "repeatSubmission",
|
||||
"kind": "repeatActivity",
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -167,7 +172,7 @@ Below is a configuration fulfilling the example given at the start of this readm
|
||||
"kind": "submission",
|
||||
"rules": [
|
||||
{
|
||||
"kind": "repeatSubmission",
|
||||
"kind": "repeatActivity",
|
||||
"gapAllowance": 2,
|
||||
"threshold": 10
|
||||
}
|
||||
@@ -214,20 +219,26 @@ Below is a configuration fulfilling the example given at the start of this readm
|
||||
|
||||
## Usage
|
||||
|
||||
`npm run start [list,of,subreddits] [...--options]`
|
||||
```
|
||||
Usage: index [options] [command]
|
||||
|
||||
CLI options take precedence over environmental variables
|
||||
Options:
|
||||
-c, --clientId <id> Client ID for your Reddit application (default: process.env.CLIENT_ID)
|
||||
-e, --clientSecret <secret> Client Secret for your Reddit application (default: process.env.CLIENT_SECRET)
|
||||
-a, --accessToken <token> Access token retrieved from authenticating an account with your Reddit Application (default: process.env.ACCESS_TOKEN)
|
||||
-r, --refreshToken <token> Refresh token retrieved from authenticating an account with your Reddit Application (default: process.env.REFRESH_TOKEN)
|
||||
-s, --subreddits <list...> List of subreddits to run on. Bot will run on all subs it has access to if not defined (default: process.env.SUBREDDITS as comma-separated string)
|
||||
-d, --logDir <dir> Absolute path to directory to store rotated logs in (default: process.env.LOG_DIR || 'CWD/logs')
|
||||
-l, --logLevel <level> Log level (default: process.env.LOG_LEVEL || 'info')
|
||||
-w, --wikiConfig <path> Relative url to contextbot wiki page EX https://reddit.com/r/subreddit/wiki/<path> (default: process.env.WIKI_CONFIG || 'botconfig/contextbot')
|
||||
-n, --snooDebug Set Snoowrap to debug (default: process.env.SNOO_DEBUG || false)
|
||||
-h, --help display help for command
|
||||
|
||||
| CLI | Environmental Variable | Required | Description |
|
||||
|------------------|------------------------|----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [First Argument] | | No | Comma-deliminated list of subreddits to run on if you don't want to run all the account has access to. |
|
||||
| --clientId | CLIENT_ID | **Yes** | Your reddit application client id |
|
||||
| --clientSecret | CLIENT_SECRET | **Yes** | Your reddit application client secret |
|
||||
| --accessToken | ACCESS_TOKEN | **Yes** | A valid access token retrieved from completing the oauth flow for a user with your application. |
|
||||
| --refreshToken | REFRESH_TOKEN | **Yes** | A valid refresh token retrieved from completing the oauth flow for a user with your application. |
|
||||
| --logDir | LOG_DIR | No | The absolute path to where logs should be stored. use `false` to turn off log files. Defaults to `CWD/logs` |
|
||||
| --logLevel | LOG_LEVEL | No | The minimum level to log at. Uses [Winston Log Levels](https://github.com/winstonjs/winston#logging-levels). Defaults to `info` |
|
||||
| --wikiConfig | WIKI_CONFIG | No | The location of the bot configuration in the subreddit wiki. Defaults to `botconfig/contextbox` |
|
||||
Commands:
|
||||
run Runs bot normally (unattended)
|
||||
check <activityId> [type] [checkNames...] Run check(s) on a specific activity, then exits
|
||||
help [command] display help for command
|
||||
```
|
||||
|
||||
### Reddit App??
|
||||
|
||||
@@ -260,7 +271,7 @@ Visit https://not-an-aardvark.github.io/reddit-oauth-helper/
|
||||
* report
|
||||
* submit
|
||||
* wikiread
|
||||
* Click **Generate tokens*, you will get a popup asking you to approve access (or login) -- **the account you approve access with is the account that Bot will control.**
|
||||
* Click **Generate tokens**, you will get a popup asking you to approve access (or login) -- **the account you approve access with is the account that Bot will control.**
|
||||
* After approving an **Access Token** and **Refresh Token** will be shown at the bottom of the page. Write these down.
|
||||
|
||||
You should now have all the information you need to start the bot.
|
||||
|
||||
33
app.json
Normal file
33
app.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "Reddit Context Bot",
|
||||
"description": "An event-based, reddit moderation bot built on top of snoowrap and written in typescript",
|
||||
"repository": "https://github.com/FoxxMD/reddit-context-bot",
|
||||
"stack": "container",
|
||||
"env": {
|
||||
"CLIENT_ID": {
|
||||
"description": "Client ID for your Reddit application",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
"CLIENT_SECRET": {
|
||||
"description": "Client Secret for your Reddit application",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
"REFRESH_TOKEN": {
|
||||
"description": "Refresh token retrieved from authenticating an account with your Reddit Application",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
"ACCESS_TOKEN": {
|
||||
"description": "Access token retrieved from authenticating an account with your Reddit Application",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
"WIKI_CONFIG": {
|
||||
"description": "Relative url to contextbot wiki page EX https://reddit.com/r/subreddit/wiki/<path>",
|
||||
"value": "botconfig/contextbot",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
3
heroku.yml
Normal file
3
heroku.yml
Normal file
@@ -0,0 +1,3 @@
|
||||
build:
|
||||
docker:
|
||||
worker: Dockerfile
|
||||
84
package-lock.json
generated
84
package-lock.json
generated
@@ -9,16 +9,16 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
"commander": "^7.2.0",
|
||||
"dayjs": "^1.10.5",
|
||||
"es6-error": "^4.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"minimist": "^1.2.5",
|
||||
"mustache": "^4.2.0",
|
||||
"p-event": "^4.2.0",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"snoostorm": "^1.5.2",
|
||||
"snoowrap": "^1.23.0",
|
||||
"winston": "FoxxMD/winston#9639da027cd4f3b46b055b0193f240639ef53409",
|
||||
"winston": "FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
|
||||
"winston-daily-rotate-file": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -58,12 +58,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.scandir": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
|
||||
"integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==",
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@nodelib/fs.stat": "2.0.4",
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
},
|
||||
"engines": {
|
||||
@@ -71,21 +71,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.stat": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz",
|
||||
"integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/@nodelib/fs.walk": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz",
|
||||
"integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==",
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.7.tgz",
|
||||
"integrity": "sha512-BTIhocbPBSrRmHxOAJFtR18oLhxTtAFDAvL8hY1S3iU8k+E60W/YFs4jrixGzQjMpF4qPXxIQHcjVD9dz1C2QA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@nodelib/fs.scandir": "2.1.4",
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -155,9 +155,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "15.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.0.tgz",
|
||||
"integrity": "sha512-+aHJvoCsVhO2ZCuT4o5JtcPrCPyDE3+1nvbDprYes+pPkEsbjH7AGUCNtjMOXS0fqH14t+B7yLzaqSz92FPWyw==",
|
||||
"version": "15.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.1.tgz",
|
||||
"integrity": "sha512-zyxJM8I1c9q5sRMtVF+zdd13Jt6RU4r4qfhTd7lQubyThvLfx6yYekWSQjGCGV2Tkecgxnlpl/DNlb6Hg+dmEw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/strip-bom": {
|
||||
@@ -488,7 +488,6 @@
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
@@ -1094,11 +1093,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
|
||||
},
|
||||
"node_modules/mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
@@ -1898,8 +1892,8 @@
|
||||
},
|
||||
"node_modules/winston": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "git+ssh://git@github.com/FoxxMD/winston.git#9639da027cd4f3b46b055b0193f240639ef53409",
|
||||
"integrity": "sha512-StxHu2puJAl2Ky8mXitI2nQ7lDNT5PPS4cnTj+2FF4orQSKVRIGhulMkRpoAtk9Z40QuhkgNRfCmBGQXa30BZQ==",
|
||||
"resolved": "git+ssh://git@github.com/FoxxMD/winston.git#fbab8de969ecee578981c77846156c7f43b5f01e",
|
||||
"integrity": "sha512-OnunfctuocKBmG0uzkBkVYGSW13VYmzglQPwj4ZFOBEtV2e2ECqe65eCaYRYHSvKynGc8T5hiaEWAVR2hVj+Yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
@@ -2134,28 +2128,28 @@
|
||||
}
|
||||
},
|
||||
"@nodelib/fs.scandir": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.4.tgz",
|
||||
"integrity": "sha512-33g3pMJk3bg5nXbL/+CY6I2eJDzZAni49PfJnL5fghPTggPvBd/pFNSgJsdAgWptuFu7qq/ERvOYFlhvsLTCKA==",
|
||||
"version": "2.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@nodelib/fs.stat": "2.0.4",
|
||||
"@nodelib/fs.stat": "2.0.5",
|
||||
"run-parallel": "^1.1.9"
|
||||
}
|
||||
},
|
||||
"@nodelib/fs.stat": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.4.tgz",
|
||||
"integrity": "sha512-IYlHJA0clt2+Vg7bccq+TzRdJvv19c2INqBSsoOLp1je7xjtr7J26+WXR72MCdvU9q1qTzIWDfhMf+DRvQJK4Q==",
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||
"dev": true
|
||||
},
|
||||
"@nodelib/fs.walk": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.6.tgz",
|
||||
"integrity": "sha512-8Broas6vTtW4GIXTAHDoE32hnN2M5ykgCpWGbuXHQ15vEMqr23pB76e/GZcYsZCHALv50ktd24qhEyKr6wBtow==",
|
||||
"version": "1.2.7",
|
||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.7.tgz",
|
||||
"integrity": "sha512-BTIhocbPBSrRmHxOAJFtR18oLhxTtAFDAvL8hY1S3iU8k+E60W/YFs4jrixGzQjMpF4qPXxIQHcjVD9dz1C2QA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@nodelib/fs.scandir": "2.1.4",
|
||||
"@nodelib/fs.scandir": "2.1.5",
|
||||
"fastq": "^1.6.0"
|
||||
}
|
||||
},
|
||||
@@ -2222,9 +2216,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "15.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.0.tgz",
|
||||
"integrity": "sha512-+aHJvoCsVhO2ZCuT4o5JtcPrCPyDE3+1nvbDprYes+pPkEsbjH7AGUCNtjMOXS0fqH14t+B7yLzaqSz92FPWyw==",
|
||||
"version": "15.12.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-15.12.1.tgz",
|
||||
"integrity": "sha512-zyxJM8I1c9q5sRMtVF+zdd13Jt6RU4r4qfhTd7lQubyThvLfx6yYekWSQjGCGV2Tkecgxnlpl/DNlb6Hg+dmEw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/strip-bom": {
|
||||
@@ -2504,8 +2498,7 @@
|
||||
"commander": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="
|
||||
},
|
||||
"concat-map": {
|
||||
"version": "0.0.1",
|
||||
@@ -2998,11 +2991,6 @@
|
||||
"brace-expansion": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw=="
|
||||
},
|
||||
"mkdirp": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
|
||||
@@ -3578,9 +3566,9 @@
|
||||
}
|
||||
},
|
||||
"winston": {
|
||||
"version": "git+ssh://git@github.com/FoxxMD/winston.git#9639da027cd4f3b46b055b0193f240639ef53409",
|
||||
"integrity": "sha512-StxHu2puJAl2Ky8mXitI2nQ7lDNT5PPS4cnTj+2FF4orQSKVRIGhulMkRpoAtk9Z40QuhkgNRfCmBGQXa30BZQ==",
|
||||
"from": "winston@FoxxMD/winston#9639da027cd4f3b46b055b0193f240639ef53409",
|
||||
"version": "git+ssh://git@github.com/FoxxMD/winston.git#fbab8de969ecee578981c77846156c7f43b5f01e",
|
||||
"integrity": "sha512-OnunfctuocKBmG0uzkBkVYGSW13VYmzglQPwj4ZFOBEtV2e2ECqe65eCaYRYHSvKynGc8T5hiaEWAVR2hVj+Yg==",
|
||||
"from": "winston@FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
|
||||
"requires": {
|
||||
"@dabh/diagnostics": "^2.0.2",
|
||||
"async": "^3.1.0",
|
||||
|
||||
14
package.json
14
package.json
@@ -10,10 +10,12 @@
|
||||
"guard": "ts-auto-guard src/JsonConfig.ts",
|
||||
"schema": "npm run -s schema-app & npm run -s schema-ruleset & npm run -s schema-rule & npm run -s schema-action",
|
||||
"schema-app": "typescript-json-schema tsconfig.json JSONConfig --out src/Schema/App.json --required --tsNodeRegister --refs --propOrder",
|
||||
"schema-ruleset": "typescript-json-schema tsconfig.json RuleSetJSONConfig --out src/Schema/RuleSet.json --required --tsNodeRegister --refs --propOrder",
|
||||
"schema-rule": "typescript-json-schema tsconfig.json RuleJSONConfig --out src/Schema/Rule.json --required --tsNodeRegister --refs --propOrder",
|
||||
"schema-action": "typescript-json-schema tsconfig.json ActionJSONConfig --out src/Schema/Action.json --required --tsNodeRegister --refs --propOrder",
|
||||
"schemaNotWorking": "./node_modules/.bin/ts-json-schema-generator -f tsconfig.json -p src/JsonConfig.ts -t JSONConfig --out src/Schema/vegaSchema.json"
|
||||
"schema-ruleset": "typescript-json-schema tsconfig.json RuleSetJson --out src/Schema/RuleSet.json --required --tsNodeRegister --refs --propOrder",
|
||||
"schema-rule": "typescript-json-schema tsconfig.json RuleJson --out src/Schema/Rule.json --required --tsNodeRegister --refs --propOrder",
|
||||
"schema-action": "typescript-json-schema tsconfig.json ActionJson --out src/Schema/Action.json --required --tsNodeRegister --refs --propOrder",
|
||||
"schemaNotWorking": "./node_modules/.bin/ts-json-schema-generator -f tsconfig.json -p src/JsonConfig.ts -t JSONConfig --out src/Schema/vegaSchema.json",
|
||||
"circular": "madge --circular --extensions ts src/index.ts",
|
||||
"circular-graph": "madge --image graph.svg --circular --extensions ts src/index.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=15"
|
||||
@@ -23,16 +25,16 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.6",
|
||||
"commander": "^7.2.0",
|
||||
"dayjs": "^1.10.5",
|
||||
"es6-error": "^4.1.1",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"minimist": "^1.2.5",
|
||||
"mustache": "^4.2.0",
|
||||
"p-event": "^4.2.0",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"snoostorm": "^1.5.2",
|
||||
"snoowrap": "^1.23.0",
|
||||
"winston": "FoxxMD/winston#9639da027cd4f3b46b055b0193f240639ef53409",
|
||||
"winston": "FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
|
||||
"winston-daily-rotate-file": "^4.5.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import {CommentAction, CommentActionJSONConfig} from "./CommentAction";
|
||||
import {CommentAction, CommentActionJson} from "./CommentAction";
|
||||
import LockAction from "./LockAction";
|
||||
import {RemoveAction} from "./RemoveAction";
|
||||
import {ReportAction, ReportActionJSONConfig} from "./ReportAction";
|
||||
import {FlairAction, FlairActionJSONConfig} from "./SubmissionAction/FlairAction";
|
||||
import Action, {ActionJSONConfig} from "./index";
|
||||
import {ReportAction, ReportActionJson} from "./ReportAction";
|
||||
import {FlairAction, FlairActionJson} from "./SubmissionAction/FlairAction";
|
||||
import Action, {ActionJson} from "./index";
|
||||
|
||||
export function actionFactory
|
||||
(config: ActionJSONConfig): Action {
|
||||
(config: ActionJson): Action {
|
||||
switch (config.kind) {
|
||||
case 'comment':
|
||||
return new CommentAction(config as CommentActionJSONConfig);
|
||||
return new CommentAction(config as CommentActionJson);
|
||||
case 'lock':
|
||||
return new LockAction();
|
||||
case 'remove':
|
||||
return new RemoveAction();
|
||||
case 'report':
|
||||
return new ReportAction(config as ReportActionJSONConfig);
|
||||
return new ReportAction(config as ReportActionJson);
|
||||
case 'flair':
|
||||
return new FlairAction(config as FlairActionJSONConfig);
|
||||
return new FlairAction(config as FlairActionJson);
|
||||
default:
|
||||
throw new Error('rule "kind" was not recognized.');
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Action, {ActionJSONConfig, ActionConfig, ActionOptions} from "./index";
|
||||
import Action, {ActionJson, ActionConfig, ActionOptions} from "./index";
|
||||
import Snoowrap, {Comment} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
@@ -81,6 +81,6 @@ export interface CommentActionOptions extends CommentActionConfig,ActionOptions
|
||||
/**
|
||||
* Reply to the Activity. For a submission the reply will be a top-level comment.
|
||||
* */
|
||||
export interface CommentActionJSONConfig extends CommentActionConfig, ActionJSONConfig {
|
||||
export interface CommentActionJson extends CommentActionConfig, ActionJson {
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ActionJSONConfig, ActionConfig} from "./index";
|
||||
import {ActionJson, ActionConfig} from "./index";
|
||||
import Action from "./index";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {RuleResult} from "../Rule";
|
||||
@@ -20,7 +20,7 @@ export interface LockActionConfig extends ActionConfig {
|
||||
/**
|
||||
* Lock the Activity
|
||||
* */
|
||||
export interface LockActionJSONConfig extends LockActionConfig, ActionJSONConfig {
|
||||
export interface LockActionJson extends LockActionConfig, ActionJson {
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ActionJSONConfig, ActionConfig} from "./index";
|
||||
import {ActionJson, ActionConfig} from "./index";
|
||||
import Action from "./index";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {RuleResult} from "../Rule";
|
||||
@@ -18,6 +18,6 @@ export interface RemoveActionConfig extends ActionConfig {
|
||||
/**
|
||||
* Remove the Activity
|
||||
* */
|
||||
export interface RemoveActionJSONConfig extends RemoveActionConfig, ActionJSONConfig {
|
||||
export interface RemoveActionJson extends RemoveActionConfig, ActionJson {
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ActionJSONConfig, ActionConfig, ActionOptions} from "./index";
|
||||
import {ActionJson, ActionConfig, ActionOptions} from "./index";
|
||||
import Action from "./index";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {truncateStringToLength} from "../util";
|
||||
@@ -7,7 +7,8 @@ import {RuleResult} from "../Rule";
|
||||
|
||||
// https://www.reddit.com/dev/api/oauth#POST_api_report
|
||||
// denotes 100 characters maximum
|
||||
const reportTrunc = truncateStringToLength(100);
|
||||
// const reportTrunc = truncateStringToLength(100);
|
||||
// actually only applies to VISIBLE text on OLD reddit... on old reddit rest of text is visible on hover. on new reddit the whole thing displays (up to at least 400 characters)
|
||||
|
||||
export class ReportAction extends Action {
|
||||
content: string;
|
||||
@@ -20,9 +21,9 @@ export class ReportAction extends Action {
|
||||
|
||||
async handle(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
|
||||
const renderedContent = await renderContent(this.content, item, ruleResults);
|
||||
const truncatedContent = reportTrunc(renderedContent);
|
||||
//const truncatedContent = reportTrunc(renderedContent);
|
||||
// @ts-ignore
|
||||
await item.report({reason: truncatedContent});
|
||||
await item.report({reason: renderedContent});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +40,6 @@ export interface ReportActionOptions extends ReportActionConfig, ActionOptions {
|
||||
/**
|
||||
* Report the Activity
|
||||
* */
|
||||
export interface ReportActionJSONConfig extends ReportActionConfig, ActionJSONConfig {
|
||||
export interface ReportActionJson extends ReportActionConfig, ActionJson {
|
||||
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {SubmissionActionConfig} from "./index";
|
||||
import Action, {ActionJSONConfig} from "../index";
|
||||
import Action, {ActionJson} from "../index";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {RuleResult} from "../../Rule";
|
||||
|
||||
@@ -43,6 +43,6 @@ export interface FlairActionOptions extends SubmissionActionConfig {
|
||||
/**
|
||||
* Flair the Submission
|
||||
* */
|
||||
export interface FlairActionJSONConfig extends FlairActionOptions, ActionJSONConfig {
|
||||
export interface FlairActionJson extends FlairActionOptions, ActionJson {
|
||||
|
||||
}
|
||||
|
||||
@@ -44,13 +44,16 @@ export interface ActionConfig {
|
||||
name?: string;
|
||||
}
|
||||
|
||||
/** @see {isActionConfig} ts-auto-guard:type-guard */
|
||||
export interface ActionJSONConfig extends ActionConfig {
|
||||
export interface ActionJson extends ActionConfig {
|
||||
/**
|
||||
* The type of action that will be performed
|
||||
*/
|
||||
kind: 'comment' | 'lock' | 'remove' | 'report' | 'flair'
|
||||
}
|
||||
|
||||
export const isActionJson = (obj: object): obj is ActionJson => {
|
||||
return (obj as ActionJson).kind !== undefined;
|
||||
}
|
||||
|
||||
export default Action;
|
||||
|
||||
|
||||
169
src/App.ts
Normal file
169
src/App.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import Snoowrap from "snoowrap";
|
||||
import {Manager} from "./Subreddit/Manager";
|
||||
import winston, {Logger} from "winston";
|
||||
import {labelledFormat, loggerMetaShuffle} from "./util";
|
||||
import snoowrap from "snoowrap";
|
||||
import pEvent from "p-event";
|
||||
import EventEmitter from "events";
|
||||
|
||||
const {transports} = winston;
|
||||
|
||||
export class App {
|
||||
|
||||
client: Snoowrap;
|
||||
subreddits: string[];
|
||||
subManagers: Manager[] = [];
|
||||
logger: Logger;
|
||||
wikiLocation: string;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
const {
|
||||
subreddits = [],
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
logDir,
|
||||
logLevel,
|
||||
wikiConfig,
|
||||
snooDebug,
|
||||
version,
|
||||
} = options;
|
||||
|
||||
this.wikiLocation = wikiConfig;
|
||||
|
||||
const consoleTransport = new transports.Console();
|
||||
|
||||
const myTransports = [
|
||||
consoleTransport,
|
||||
];
|
||||
|
||||
if (logDir !== false) {
|
||||
let logPath = logDir;
|
||||
if (logPath === true) {
|
||||
logPath = `${process.cwd()}/logs`;
|
||||
}
|
||||
const rotateTransport = new winston.transports.DailyRotateFile({
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'contextBot-current.log',
|
||||
filename: 'contextBot-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
});
|
||||
// @ts-ignore
|
||||
myTransports.push(rotateTransport);
|
||||
}
|
||||
|
||||
const loggerOptions = {
|
||||
level: logLevel || 'info',
|
||||
format: labelledFormat(),
|
||||
transports: myTransports,
|
||||
levels: {
|
||||
error: 0,
|
||||
warn: 1,
|
||||
info: 2,
|
||||
http: 3,
|
||||
verbose: 4,
|
||||
debug: 5,
|
||||
trace: 5,
|
||||
silly: 6
|
||||
}
|
||||
};
|
||||
|
||||
winston.loggers.add('default', loggerOptions);
|
||||
|
||||
this.logger = winston.loggers.get('default');
|
||||
|
||||
let subredditsArg = [];
|
||||
if (subreddits !== undefined) {
|
||||
if (Array.isArray(subreddits)) {
|
||||
subredditsArg = subreddits;
|
||||
} else {
|
||||
subredditsArg = subreddits.split(',');
|
||||
}
|
||||
}
|
||||
this.subreddits = subredditsArg;
|
||||
|
||||
const creds = {
|
||||
userAgent: `web:contextBot:${version}`,
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
};
|
||||
|
||||
let shouldDebug = snooDebug === false && process.env.SNOO_DEBUG === 'true' ? true : snooDebug === true || snooDebug === 'true';
|
||||
this.client = new snoowrap(creds);
|
||||
this.client.config({
|
||||
warnings: true,
|
||||
maxRetryAttempts: 5,
|
||||
debug: shouldDebug,
|
||||
// @ts-ignore
|
||||
logger: this.logger.child(loggerMetaShuffle(this.logger, undefined, ['Snoowrap'])),
|
||||
continueAfterRatelimitError: true,
|
||||
});
|
||||
}
|
||||
|
||||
async buildManagers(subreddits: string[] = []) {
|
||||
let availSubs = [];
|
||||
for (const sub of await this.client.getModeratedSubreddits()) {
|
||||
// TODO don't know a way to check permissions yet
|
||||
availSubs.push(sub);
|
||||
}
|
||||
|
||||
let subsToRun = [];
|
||||
const subsToUse = subreddits.length > 0 ? subreddits : this.subreddits;
|
||||
if (subsToUse.length > 0) {
|
||||
for (const sub of subsToUse) {
|
||||
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.trim().toLowerCase())
|
||||
if (asub === undefined) {
|
||||
this.logger.error(`Will not run on ${sub} because is not modded by, or does not have appropriate permissions to mod with, for this client.`);
|
||||
} else {
|
||||
subsToRun.push(asub);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// otherwise assume all moddable subs from client should be run on
|
||||
subsToRun = availSubs;
|
||||
}
|
||||
|
||||
let subSchedule: Manager[] = [];
|
||||
// get configs for subs we want to run on and build/validate them
|
||||
for (const sub of subsToRun) {
|
||||
let content = undefined;
|
||||
let json = undefined;
|
||||
try {
|
||||
const wiki = sub.getWikiPage(this.wikiLocation);
|
||||
content = await wiki.content_md;
|
||||
} catch (err) {
|
||||
this.logger.error(`Could not read wiki configuration for ${sub.display_name}. Please ensure the page 'contextbot' exists and is readable -- error: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error(`Wiki page contents for ${sub.display_name} was not valid -- error: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
subSchedule.push(new Manager(sub, this.client, this.logger, json));
|
||||
} catch (err) {
|
||||
debugger;
|
||||
this.logger.error(`Config for ${sub.display_name} was not valid, will not run for this subreddit`, undefined, err);
|
||||
}
|
||||
}
|
||||
this.subManagers = subSchedule;
|
||||
}
|
||||
|
||||
async runManagers() {
|
||||
|
||||
for (const manager of this.subManagers) {
|
||||
manager.handle();
|
||||
}
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
await pEvent(emitter, 'end');
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,17 @@
|
||||
import {RuleSet, IRuleSet, RuleSetJSONConfig} from "../Rule/RuleSet";
|
||||
import {IRule, Triggerable, Rule, RuleJSONConfig, RuleResult} from "../Rule";
|
||||
import Action, {ActionConfig, ActionJSONConfig} from "../Action";
|
||||
import {RuleSet, IRuleSet, RuleSetJson, RuleSetObjectJson} from "../Rule/RuleSet";
|
||||
import {IRule,Rule, RuleJSONConfig, RuleResult} from "../Rule";
|
||||
import Action, {ActionConfig, ActionJson} from "../Action";
|
||||
import {Logger} from "winston";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {RecentActivityRuleJSONConfig} from "../Rule/RecentActivityRule";
|
||||
import {RepeatSubmissionJSONConfig} from "../Rule/SubmissionRule/RepeatSubmissionRule";
|
||||
import {FlairActionJSONConfig} from "../Action/SubmissionAction/FlairAction";
|
||||
import {CommentActionJSONConfig} from "../Action/CommentAction";
|
||||
import {Comment, Submission} from "snoowrap";
|
||||
import {actionFactory} from "../Action/ActionFactory";
|
||||
import {ruleFactory} from "../Rule/RuleFactory";
|
||||
import {createLabelledLogger, determineNewResults, loggerMetaShuffle, mergeArr} from "../util";
|
||||
import {AuthorRuleJSONConfig} from "../Rule/AuthorRule";
|
||||
import {ReportActionJSONConfig} from "../Action/ReportAction";
|
||||
import {LockActionJSONConfig} from "../Action/LockAction";
|
||||
import {RemoveActionJSONConfig} from "../Action/RemoveAction";
|
||||
import {createLabelledLogger, loggerMetaShuffle, mergeArr} from "../util";;
|
||||
import {JoinCondition, JoinOperands} from "../Common/interfaces";
|
||||
import * as RuleSchema from '../Schema/Rule.json';
|
||||
import * as RuleSetSchema from '../Schema/RuleSet.json';
|
||||
import * as ActionSchema from '../Schema/Action.json';
|
||||
import Ajv from 'ajv';
|
||||
import {ActionObjectJson, RuleJson, RuleObjectJson, ActionJson as ActionTypeJson} from "../Common/types";
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
@@ -59,7 +52,7 @@ export class Check implements ICheck {
|
||||
if (valid) {
|
||||
// @ts-ignore
|
||||
r.logger = this.logger;
|
||||
this.rules.push(new RuleSet(r as RuleSetJSONConfig));
|
||||
this.rules.push(new RuleSet(r as RuleSetObjectJson));
|
||||
} else {
|
||||
setErrors = ajv.errors;
|
||||
valid = ajv.validate(RuleSchema, r);
|
||||
@@ -86,7 +79,7 @@ export class Check implements ICheck {
|
||||
} else {
|
||||
let valid = ajv.validate(ActionSchema, a);
|
||||
if (valid) {
|
||||
this.actions.push(actionFactory(a as ActionJSONConfig));
|
||||
this.actions.push(actionFactory(a as ActionJson));
|
||||
// @ts-ignore
|
||||
a.logger = this.logger;
|
||||
} else {
|
||||
@@ -99,7 +92,7 @@ export class Check implements ICheck {
|
||||
}
|
||||
|
||||
async run(item: Submission | Comment, existingResults: RuleResult[] = []): Promise<[boolean, RuleResult[]]> {
|
||||
this.logger.debug('Starting check');
|
||||
//this.logger.debug('Starting check');
|
||||
let allResults: RuleResult[] = [];
|
||||
let runOne = false;
|
||||
for (const r of this.rules) {
|
||||
@@ -150,23 +143,29 @@ export interface CheckOptions extends ICheck {
|
||||
logger?: Logger
|
||||
}
|
||||
|
||||
/**
|
||||
* An object consisting of Rules (tests) and Actions to perform if Rules are triggered
|
||||
* @see {isCheckConfig} ts-auto-guard:type-guard
|
||||
* */
|
||||
export interface CheckJSONConfig extends ICheck {
|
||||
export interface CheckJson extends ICheck {
|
||||
/**
|
||||
* The type of event (new submission or new comment) this check should be run against
|
||||
*/
|
||||
kind: 'submission' | 'comment'
|
||||
/**
|
||||
* Rules are run in the order found in configuration. Can be Rules or RuleSets
|
||||
* A list of Rules to run. If `Rule` objects are triggered based on `condition` then `Actions` will be performed.
|
||||
*
|
||||
* Can be `Rule`, `RuleSet`, or the `name` of any **named** `Rule` in your subreddit's configuration
|
||||
* @minItems 1
|
||||
* */
|
||||
rules: Array<RuleSetJSONConfig | RecentActivityRuleJSONConfig | RepeatSubmissionJSONConfig | AuthorRuleJSONConfig>
|
||||
rules: Array<RuleSetJson | RuleJson>
|
||||
/**
|
||||
* The actions to run after the check is successfully triggered. ALL actions will run in the order they are listed
|
||||
* The `Actions` to run after the check is successfully triggered. ALL `Actions` will run in the order they are listed
|
||||
*
|
||||
* Can be `Action` or the `name` of any **named** `Action` in your subreddit's configuration
|
||||
*
|
||||
* @minItems 1
|
||||
* */
|
||||
actions: Array<FlairActionJSONConfig | CommentActionJSONConfig | ReportActionJSONConfig | LockActionJSONConfig | RemoveActionJSONConfig>
|
||||
actions: Array<ActionTypeJson>
|
||||
}
|
||||
|
||||
export interface CheckStructuredJson extends CheckJson {
|
||||
rules: Array<RuleSetObjectJson | RuleObjectJson>
|
||||
actions: Array<ActionObjectJson>
|
||||
}
|
||||
|
||||
@@ -122,11 +122,11 @@ export type JoinOperands = 'OR' | 'AND';
|
||||
|
||||
export interface JoinCondition {
|
||||
/**
|
||||
* Under what condition should a set of rules be considered "successful"?
|
||||
* Under what condition should a set of run `Rule` objects be considered "successful"?
|
||||
*
|
||||
* If "OR" then ANY triggered rule results in success.
|
||||
* If `OR` then **any** triggered `Rule` object results in success.
|
||||
*
|
||||
* If "AND" then ALL rules must be triggered to result in success.
|
||||
* If `AND` then **all** `Rule` objects must be triggered to result in success.
|
||||
*
|
||||
* @default "AND"
|
||||
* */
|
||||
@@ -170,3 +170,16 @@ export interface PollingOptions {
|
||||
interval?: number,
|
||||
}
|
||||
}
|
||||
|
||||
export interface ManagerOptions {
|
||||
polling?: PollingOptions
|
||||
/**
|
||||
* If present, time in milliseconds between HEARTBEAT log statements with current api limit count. Nice to have to know things are still ticking if there is low activity
|
||||
* */
|
||||
heartbeatInterval?: number
|
||||
/**
|
||||
* When Reddit API limit remaining reaches this number context bot will start warning on every poll interval
|
||||
* @default 250
|
||||
* */
|
||||
apiLimitWarning?: number
|
||||
}
|
||||
|
||||
15
src/Common/types.ts
Normal file
15
src/Common/types.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import {RecentActivityRuleJSONConfig} from "../Rule/RecentActivityRule";
|
||||
import {RepeatActivityJSONConfig} from "../Rule/SubmissionRule/RepeatActivityRule";
|
||||
import {AuthorRuleJSONConfig} from "../Rule/AuthorRule";
|
||||
import {AttributionJSONConfig} from "../Rule/SubmissionRule/AttributionRule";
|
||||
import {FlairActionJson} from "../Action/SubmissionAction/FlairAction";
|
||||
import {CommentActionJson} from "../Action/CommentAction";
|
||||
import {ReportActionJson} from "../Action/ReportAction";
|
||||
import {LockActionJson} from "../Action/LockAction";
|
||||
import {RemoveActionJson} from "../Action/RemoveAction";
|
||||
|
||||
export type RuleJson = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | string;
|
||||
export type RuleObjectJson = Exclude<RuleJson, string>
|
||||
|
||||
export type ActionJson = FlairActionJson | CommentActionJson | ReportActionJson | LockActionJson | RemoveActionJson | string;
|
||||
export type ActionObjectJson = Exclude<ActionJson, string>;
|
||||
@@ -7,7 +7,12 @@ import Ajv from 'ajv';
|
||||
import * as schema from './Schema/App.json';
|
||||
import {JSONConfig} from "./JsonConfig";
|
||||
import LoggedError from "./Utils/LoggedError";
|
||||
import {ManagerOptions} from "./Subreddit/Manager";
|
||||
import {CheckStructuredJson} from "./Check";
|
||||
import {ManagerOptions} from "./Common/interfaces";
|
||||
import {isRuleSetJSON, RuleSetJson, RuleSetObjectJson} from "./Rule/RuleSet";
|
||||
import deepEqual from "fast-deep-equal";
|
||||
import {ActionJson, ActionObjectJson, RuleJson, RuleObjectJson} from "./Common/types";
|
||||
import {isActionJson} from "./Action";
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
@@ -30,13 +35,30 @@ export class ConfigBuilder {
|
||||
buildFromJson(config: object): [Array<SubmissionCheck>,Array<CommentCheck>,ManagerOptions] {
|
||||
const commentChecks: Array<CommentCheck> = [];
|
||||
const subChecks: Array<SubmissionCheck> = [];
|
||||
|
||||
let namedRules: Map<string,RuleObjectJson> = new Map();
|
||||
let namedActions: Map<string,ActionObjectJson> = new Map();
|
||||
|
||||
const valid = ajv.validate(schema, config);
|
||||
let managerOptions: ManagerOptions = {};
|
||||
if(valid) {
|
||||
const validConfig = config as JSONConfig;
|
||||
const {checks = [], ...rest} = validConfig;
|
||||
for(const c of checks) {
|
||||
namedRules = extractNamedRules(c.rules, namedRules);
|
||||
namedActions = extractNamedActions(c.actions, namedActions);
|
||||
}
|
||||
|
||||
const structuredChecks: CheckStructuredJson[] = [];
|
||||
for(const c of checks) {
|
||||
const strongRules = insertNamedRules(c.rules, namedRules);
|
||||
const strongActions = insertNamedActions(c.actions, namedActions);
|
||||
const strongCheck = {...c, rules: strongRules, actions: strongActions} as CheckStructuredJson;
|
||||
structuredChecks.push(strongCheck);
|
||||
}
|
||||
|
||||
managerOptions = rest;
|
||||
for (const jCheck of checks) {
|
||||
for (const jCheck of structuredChecks) {
|
||||
if (jCheck.kind === 'comment') {
|
||||
commentChecks.push(new CommentCheck({...jCheck, logger: this.logger}));
|
||||
} else if (jCheck.kind === 'submission') {
|
||||
@@ -44,11 +66,119 @@ export class ConfigBuilder {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.error('Json config was not valid. Please use schema to check validity.', ajv.errors);
|
||||
this.logger.error(ajv.errors);
|
||||
this.logger.error('Json config was not valid. Please use schema to check validity.');
|
||||
if(Array.isArray(ajv.errors)) {
|
||||
for(const err of ajv.errors) {
|
||||
let suffix = '';
|
||||
// @ts-ignore
|
||||
if(err.params.allowedValues !== undefined) {
|
||||
// @ts-ignore
|
||||
suffix = err.params.allowedValues.join(', ');
|
||||
suffix = ` [${suffix}]`;
|
||||
}
|
||||
this.logger.error(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`);
|
||||
}
|
||||
}
|
||||
throw new LoggedError();
|
||||
}
|
||||
|
||||
return [subChecks, commentChecks, managerOptions];
|
||||
}
|
||||
}
|
||||
|
||||
export const extractNamedRules = (rules: Array<RuleSetJson | RuleJson>, namedRules: Map<string, RuleObjectJson> = new Map()): Map<string, RuleObjectJson> => {
|
||||
//const namedRules = new Map();
|
||||
for (const r of rules) {
|
||||
let rulesToAdd: RuleObjectJson[] = [];
|
||||
if ((typeof r === 'object')) {
|
||||
if ((r as RuleObjectJson).kind !== undefined) {
|
||||
// itsa rule
|
||||
const rule = r as RuleObjectJson;
|
||||
if (rule.name !== undefined) {
|
||||
rulesToAdd.push(rule);
|
||||
}
|
||||
} else {
|
||||
const ruleSet = r as RuleSetJson;
|
||||
const nestedNamed = extractNamedRules(ruleSet.rules);
|
||||
rulesToAdd = [...nestedNamed.values()];
|
||||
}
|
||||
for (const rule of rulesToAdd) {
|
||||
const name = rule.name as string;
|
||||
const normalName = name.toLowerCase();
|
||||
const {name: n, ...rest} = rule;
|
||||
const ruleNoName = {...rest};
|
||||
|
||||
if (namedRules.has(normalName)) {
|
||||
const {name: nn, ...ruleRest} = namedRules.get(normalName) as RuleObjectJson;
|
||||
if (!deepEqual(ruleRest, ruleNoName)) {
|
||||
throw new Error(`Rule names must be unique (case-insensitive). Conflicting name: ${name}`);
|
||||
}
|
||||
} else {
|
||||
namedRules.set(normalName, rule);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return namedRules;
|
||||
}
|
||||
|
||||
export const insertNamedRules = (rules: Array<RuleSetJson | RuleJson>, namedRules: Map<string, RuleObjectJson> = new Map()): Array<RuleSetObjectJson | RuleObjectJson> => {
|
||||
const strongRules: Array<RuleSetObjectJson | RuleObjectJson> = [];
|
||||
for (const r of rules) {
|
||||
if (typeof r === 'string') {
|
||||
const foundRule = namedRules.get(r.toLowerCase());
|
||||
if (foundRule === undefined) {
|
||||
throw new Error(`No named Rule with the name ${r} was found`);
|
||||
}
|
||||
strongRules.push(foundRule);
|
||||
} else if (isRuleSetJSON(r)) {
|
||||
const {rules: sr, ...rest} = r;
|
||||
const setRules = insertNamedRules(sr, namedRules);
|
||||
const strongSet = {rules: setRules, ...rest} as RuleSetObjectJson;
|
||||
strongRules.push(strongSet);
|
||||
} else {
|
||||
strongRules.push(r);
|
||||
}
|
||||
}
|
||||
|
||||
return strongRules;
|
||||
}
|
||||
|
||||
export const extractNamedActions = (actions: Array<ActionJson>, namedActions: Map<string, ActionObjectJson> = new Map()): Map<string, ActionObjectJson> => {
|
||||
for (const a of actions) {
|
||||
if(!(typeof a === 'string')) {
|
||||
if (isActionJson(a) && a.name !== undefined) {
|
||||
const normalName = a.name.toLowerCase();
|
||||
const {name: n, ...rest} = a;
|
||||
const actionNoName = {...rest};
|
||||
if (namedActions.has(normalName)) {
|
||||
// @ts-ignore
|
||||
const {name: nn, ...aRest} = namedActions.get(normalName) as ActionObjectJson;
|
||||
if (!deepEqual(aRest, actionNoName)) {
|
||||
throw new Error(`Actions names must be unique (case-insensitive). Conflicting name: ${a.name}`);
|
||||
}
|
||||
} else {
|
||||
namedActions.set(normalName, a);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return namedActions;
|
||||
}
|
||||
|
||||
export const insertNamedActions = (actions: Array<ActionJson>, namedActions: Map<string, ActionObjectJson> = new Map()): Array<ActionObjectJson> => {
|
||||
const strongActions: Array<ActionObjectJson> = [];
|
||||
for (const a of actions) {
|
||||
if (typeof a === 'string') {
|
||||
const foundAction = namedActions.get(a.toLowerCase());
|
||||
if (foundAction === undefined) {
|
||||
throw new Error(`No named Action with the name ${a} was found`);
|
||||
}
|
||||
strongActions.push(foundAction);
|
||||
}else {
|
||||
strongActions.push(a);
|
||||
}
|
||||
}
|
||||
|
||||
return strongActions;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {CheckJSONConfig} from "./Check";
|
||||
import {PollingOptions} from "./Common/interfaces";
|
||||
import {CheckJson} from "./Check";
|
||||
import {ManagerOptions} from "./Common/interfaces";
|
||||
|
||||
export interface JSONConfig {
|
||||
export interface JSONConfig extends ManagerOptions {
|
||||
/**
|
||||
* A list of all the checks that should be run for a subreddit.
|
||||
*
|
||||
@@ -12,6 +12,5 @@ export interface JSONConfig {
|
||||
* When a check "passes", and actions are performed, then all subsequent checks are skipped.
|
||||
* @minItems 1
|
||||
* */
|
||||
checks: CheckJSONConfig[]
|
||||
polling?: PollingOptions
|
||||
checks: CheckJson[]
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {RecentActivityRule, RecentActivityRuleJSONConfig} from "./RecentActivityRule";
|
||||
import RepeatSubmissionRule, {RepeatSubmissionJSONConfig} from "./SubmissionRule/RepeatSubmissionRule";
|
||||
import RepeatActivityRule, {RepeatActivityJSONConfig} from "./SubmissionRule/RepeatActivityRule";
|
||||
import {Rule, RuleJSONConfig} from "./index";
|
||||
import AuthorRule, {AuthorRuleJSONConfig} from "./AuthorRule";
|
||||
import {AttributionJSONConfig, AttributionRule} from "./SubmissionRule/AttributionRule";
|
||||
|
||||
export function ruleFactory
|
||||
(config: RuleJSONConfig): Rule {
|
||||
switch (config.kind) {
|
||||
case 'recentActivity':
|
||||
return new RecentActivityRule(config as RecentActivityRuleJSONConfig);
|
||||
case 'repeatSubmission':
|
||||
return new RepeatSubmissionRule(config as RepeatSubmissionJSONConfig);
|
||||
case 'repeatActivity':
|
||||
return new RepeatActivityRule(config as RepeatActivityJSONConfig);
|
||||
case 'author':
|
||||
return new AuthorRule(config as AuthorRuleJSONConfig);
|
||||
case 'attribution':
|
||||
return new AttributionRule(config as AttributionJSONConfig);
|
||||
default:
|
||||
throw new Error('rule "kind" was not recognized.');
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import {IRule, Triggerable, Rule, RuleJSONConfig, RuleResult} from "./index";
|
||||
import {Comment, Submission} from "snoowrap";
|
||||
import {ruleFactory} from "./RuleFactory";
|
||||
import {RecentActivityRuleJSONConfig} from "./RecentActivityRule";
|
||||
import {RepeatSubmissionJSONConfig} from "./SubmissionRule/RepeatSubmissionRule";
|
||||
import {createLabelledLogger, determineNewResults, findResultByPremise, loggerMetaShuffle} from "../util";
|
||||
import {createLabelledLogger, loggerMetaShuffle} from "../util";
|
||||
import {Logger} from "winston";
|
||||
import {AuthorRuleJSONConfig} from "./AuthorRule";
|
||||
import {JoinCondition, JoinOperands} from "../Common/interfaces";
|
||||
import * as RuleSchema from '../Schema/Rule.json';
|
||||
import Ajv from 'ajv';
|
||||
import {RuleJson, RuleObjectJson} from "../Common/types";
|
||||
|
||||
const ajv = new Ajv();
|
||||
|
||||
@@ -83,12 +81,20 @@ export interface RuleSetOptions extends IRuleSet {
|
||||
}
|
||||
|
||||
/**
|
||||
* A RuleSet is a "nested" set of Rules that can be used to create more complex AND/OR behavior. Think of the outcome of a RuleSet as the result of all of it's Rules (based on condition)
|
||||
* @see {isRuleSetConfig} ts-auto-guard:type-guard
|
||||
* A RuleSet is a "nested" set of `Rule` objects that can be used to create more complex AND/OR behavior. Think of the outcome of a `RuleSet` as the result of all of its run `Rule` objects (based on `condition`)
|
||||
* */
|
||||
export interface RuleSetJSONConfig extends IRuleSet {
|
||||
export interface RuleSetJson extends JoinCondition {
|
||||
/**
|
||||
* Can be `Rule` or the `name` of any **named** `Rule` in your subreddit's configuration
|
||||
* @minItems 1
|
||||
* */
|
||||
rules: Array<RecentActivityRuleJSONConfig | RepeatSubmissionJSONConfig | AuthorRuleJSONConfig>
|
||||
rules: Array<RuleJson>
|
||||
}
|
||||
|
||||
export interface RuleSetObjectJson extends RuleSetJson {
|
||||
rules: Array<RuleObjectJson>
|
||||
}
|
||||
|
||||
export const isRuleSetJSON = (obj: object): obj is RuleSetJson => {
|
||||
return (obj as RuleSetJson).rules !== undefined;
|
||||
}
|
||||
|
||||
344
src/Rule/SubmissionRule/AttributionRule.ts
Normal file
344
src/Rule/SubmissionRule/AttributionRule.ts
Normal file
@@ -0,0 +1,344 @@
|
||||
import {SubmissionRule, SubmissionRuleJSONConfig} from "./index";
|
||||
import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../../Common/interfaces";
|
||||
import {RuleOptions, RuleResult} from "../index";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {getAttributionIdentifier, getAuthorActivities, getAuthorSubmissions} from "../../Utils/SnoowrapUtils";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
|
||||
export interface AttributionCriteria {
|
||||
/**
|
||||
* The number or percentage to trigger this rule at
|
||||
*
|
||||
* * If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at
|
||||
* * If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger
|
||||
*
|
||||
* @default 10%
|
||||
* */
|
||||
threshold: number | string
|
||||
window: ActivityWindowType
|
||||
/**
|
||||
* What activities to use for total count when determining what percentage an attribution comprises
|
||||
*
|
||||
* EX:
|
||||
*
|
||||
* Author has 100 activities, 40 are submissions and 60 are comments
|
||||
*
|
||||
* * If `submission` then if 10 submission are for Youtube Channel A then percentage => 10/40 = 25%
|
||||
* * If `all` then if 10 submission are for Youtube Channel A then percentage => 10/100 = 10%
|
||||
*
|
||||
* @default all
|
||||
**/
|
||||
thresholdOn?: 'submissions' | 'all'
|
||||
/**
|
||||
* The minimum number of activities (activities defined in `includeInTotal`) that must exist for this criteria to run
|
||||
* @default 5
|
||||
* */
|
||||
minActivityCount?: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
const defaultCriteria = [{threshold: '10%', window: 100}];
|
||||
|
||||
export class AttributionRule extends SubmissionRule {
|
||||
criteria: AttributionCriteria[];
|
||||
criteriaJoin: 'AND' | 'OR';
|
||||
useSubmissionAsReference: boolean;
|
||||
lookAt: 'media' | 'all' = 'media';
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
aggregateMediaDomains: boolean = false;
|
||||
includeSelf: boolean = false;
|
||||
|
||||
constructor(options: AttributionOptions) {
|
||||
super(options);
|
||||
const {
|
||||
criteria = defaultCriteria,
|
||||
criteriaJoin = 'OR',
|
||||
include = [],
|
||||
exclude = [],
|
||||
lookAt = 'media',
|
||||
aggregateMediaDomains = false,
|
||||
useSubmissionAsReference = true,
|
||||
includeSelf = false,
|
||||
} = options || {};
|
||||
|
||||
this.criteria = criteria;
|
||||
this.criteriaJoin = criteriaJoin;
|
||||
if (this.criteria.length === 0) {
|
||||
throw new Error('Must provide at least one AttributionCriteria');
|
||||
}
|
||||
this.include = include.map(x => x.toLowerCase());
|
||||
this.exclude = exclude.map(x => x.toLowerCase());
|
||||
this.lookAt = lookAt;
|
||||
this.aggregateMediaDomains = aggregateMediaDomains;
|
||||
this.includeSelf = includeSelf;
|
||||
this.useSubmissionAsReference = useSubmissionAsReference;
|
||||
}
|
||||
|
||||
getKind(): string {
|
||||
return "Attribution";
|
||||
}
|
||||
|
||||
protected getSpecificPremise(): object {
|
||||
return {
|
||||
criteria: this.criteria,
|
||||
useSubmissionAsReference: this.useSubmissionAsReference,
|
||||
include: this.include,
|
||||
exclude: this.exclude,
|
||||
lookAt: this.lookAt,
|
||||
aggregateMediaDomains: this.aggregateMediaDomains,
|
||||
includeSelf: this.includeSelf,
|
||||
}
|
||||
}
|
||||
|
||||
protected async process(item: Submission): Promise<[boolean, RuleResult[]]> {
|
||||
const referenceUrl = await item.url;
|
||||
if (referenceUrl === undefined && this.useSubmissionAsReference) {
|
||||
throw new Error(`Cannot run Rule ${this.name} because submission is not a link`);
|
||||
}
|
||||
|
||||
const refDomain = this.aggregateMediaDomains ? item.domain : item.secure_media?.oembed?.author_url;
|
||||
const refDomainTitle = this.aggregateMediaDomains ? (item.secure_media?.oembed?.provider_name || item.domain) : item.secure_media?.oembed?.author_name;
|
||||
|
||||
// TODO reuse activities between ActivityCriteria to reduce api calls
|
||||
|
||||
let criteriaResults = [];
|
||||
|
||||
for (const criteria of this.criteria) {
|
||||
|
||||
const {threshold, window, thresholdOn = 'all', minActivityCount = 5} = criteria;
|
||||
|
||||
let percentVal;
|
||||
if (typeof threshold === 'string') {
|
||||
percentVal = Number.parseInt(threshold.replace('%', '')) / 100;
|
||||
}
|
||||
|
||||
let activities = thresholdOn === 'submissions' ? await getAuthorSubmissions(item.author, {window: window}) : await getAuthorActivities(item.author, {window: window});
|
||||
activities = activities.filter(act => {
|
||||
if (this.include.length > 0) {
|
||||
return this.include.some(x => x === act.subreddit.display_name.toLowerCase());
|
||||
} else if (this.exclude.length > 0) {
|
||||
return !this.exclude.some(x => x === act.subreddit.display_name.toLowerCase())
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (activities.length < minActivityCount) {
|
||||
continue;
|
||||
}
|
||||
//const activities = await getAuthorSubmissions(item.author, {window: window}) as Submission[];
|
||||
|
||||
const submissions: Submission[] = thresholdOn === 'submissions' ? activities as Submission[] : activities.filter(x => x instanceof Submission) as Submission[];
|
||||
const aggregatedSubmissions = submissions.reduce((acc: Map<string, number>, sub) => {
|
||||
if (this.lookAt === 'media' && sub.secure_media === undefined) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const domain = getAttributionIdentifier(sub, this.aggregateMediaDomains)
|
||||
|
||||
if ((sub.is_self || sub.is_video || domain === 'i.redd.it') && !this.includeSelf) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const count = acc.get(domain) || 0;
|
||||
|
||||
acc.set(domain, count + 1);
|
||||
|
||||
return acc;
|
||||
}, new Map());
|
||||
|
||||
let activityTotal = 0;
|
||||
let firstActivity, lastActivity;
|
||||
|
||||
activityTotal = activities.length;
|
||||
firstActivity = activities[0];
|
||||
lastActivity = activities[activities.length - 1];
|
||||
|
||||
// if (this.includeInTotal === 'submissions') {
|
||||
// activityTotal = activities.length;
|
||||
// firstActivity = activities[0];
|
||||
// lastActivity = activities[activities.length - 1];
|
||||
// } else {
|
||||
// const dur = typeof window === 'number' ? dayjs.duration(dayjs().diff(dayjs(activities[activities.length - 1].created * 1000))) : window;
|
||||
// const allActivities = await getAuthorActivities(item.author, {window: dur});
|
||||
// activityTotal = allActivities.length;
|
||||
// firstActivity = allActivities[0];
|
||||
// lastActivity = allActivities[allActivities.length - 1];
|
||||
// }
|
||||
|
||||
const activityTotalWindow = dayjs.duration(dayjs(firstActivity.created_utc * 1000).diff(dayjs(lastActivity.created_utc * 1000)));
|
||||
|
||||
let triggeredDomains = [];
|
||||
for (const [domain, subCount] of aggregatedSubmissions) {
|
||||
let triggered = false;
|
||||
if (percentVal !== undefined) {
|
||||
|
||||
triggered = percentVal <= subCount / activityTotal;
|
||||
} else if (subCount >= threshold) {
|
||||
triggered = true;
|
||||
}
|
||||
|
||||
if (triggered) {
|
||||
// look for author channel
|
||||
const withChannel = submissions.find(x => x.secure_media?.oembed?.author_url === domain || x.secure_media?.oembed?.author_name === domain);
|
||||
triggeredDomains.push({
|
||||
domain,
|
||||
title: withChannel !== undefined ? (withChannel.secure_media?.oembed?.author_name || withChannel.secure_media?.oembed?.author_url) : domain,
|
||||
count: subCount,
|
||||
percent: Math.round((subCount / activityTotal) * 100)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.useSubmissionAsReference) {
|
||||
// filter triggeredDomains to only reference
|
||||
triggeredDomains = triggeredDomains.filter(x => x.domain === refDomain || x.domain === refDomainTitle);
|
||||
}
|
||||
|
||||
criteriaResults.push({criteria, activityTotal, activityTotalWindow, triggeredDomains});
|
||||
}
|
||||
|
||||
let criteriaMeta = false;
|
||||
if (this.criteriaJoin === 'OR') {
|
||||
criteriaMeta = criteriaResults.some(x => x.triggeredDomains.length > 0);
|
||||
} else {
|
||||
criteriaMeta = criteriaResults.every(x => x.triggeredDomains.length > 0);
|
||||
}
|
||||
|
||||
if (criteriaMeta) {
|
||||
// use first triggered criteria found
|
||||
const refCriteriaResults = criteriaResults.find(x => x.triggeredDomains.length > 0);
|
||||
if (refCriteriaResults !== undefined) {
|
||||
const {
|
||||
triggeredDomains,
|
||||
activityTotal,
|
||||
activityTotalWindow,
|
||||
criteria: {threshold, window}
|
||||
} = refCriteriaResults;
|
||||
|
||||
const largestCount = triggeredDomains.reduce((acc, curr) => Math.max(acc, curr.count), 0);
|
||||
const largestPercent = triggeredDomains.reduce((acc, curr) => Math.max(acc, curr.percent), 0);
|
||||
|
||||
const data: any = {
|
||||
triggeredDomainCount: triggeredDomains.length,
|
||||
activityTotal,
|
||||
largestCount,
|
||||
largestPercent,
|
||||
threshold: threshold,
|
||||
window: typeof window === 'number' ? `${activityTotal} Items` : activityTotalWindow.humanize()
|
||||
|
||||
};
|
||||
if (this.useSubmissionAsReference) {
|
||||
data.refDomain = refDomain;
|
||||
data.refDomainTitle = refDomainTitle;
|
||||
}
|
||||
|
||||
const result = `${triggeredDomains.length} Attribution(s) met the threshold of ${threshold}, largest being ${largestCount} (${largestPercent}%) of ${activityTotal} Total -- window: ${data.window}`;
|
||||
|
||||
return Promise.resolve([true, [this.getResult(true, {
|
||||
result,
|
||||
data,
|
||||
})]]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return Promise.resolve([false, [this.getResult(false)]]);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface AttributionConfig extends ReferenceSubmission {
|
||||
|
||||
/**
|
||||
* A list threshold-window values to test attribution against
|
||||
*
|
||||
* If none is provided the default set used is:
|
||||
*
|
||||
* ```
|
||||
* threshold: 10%
|
||||
* window: 100
|
||||
* ```
|
||||
*
|
||||
* @minItems 1
|
||||
* */
|
||||
criteria?: AttributionCriteria[]
|
||||
|
||||
/**
|
||||
* * If `OR` then any set of AttributionCriteria that produce an Attribution over the threshold will trigger the rule.
|
||||
* * If `AND` then all AttributionCriteria sets must product an Attribution over the threshold to trigger the rule.
|
||||
* */
|
||||
criteriaJoin?: 'AND' | 'OR'
|
||||
|
||||
/**
|
||||
* Only include Submissions from this list of Subreddits.
|
||||
*
|
||||
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
|
||||
*
|
||||
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* */
|
||||
include?: string[],
|
||||
/**
|
||||
* Do not include Submissions from this list of Subreddits.
|
||||
*
|
||||
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
|
||||
*
|
||||
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* */
|
||||
exclude?: string[],
|
||||
|
||||
/**
|
||||
* Determines which type of attribution to look at
|
||||
*
|
||||
* * If `media` then only the author's submission history which reddit recognizes as media (youtube, vimeo, etc.) will be considered
|
||||
* * If `all` then all domains (EX youtube.com, twitter.com) from the author's submission history will be considered
|
||||
*
|
||||
* @default all
|
||||
* */
|
||||
lookAt?: 'media' | 'all',
|
||||
|
||||
/**
|
||||
* Should the rule aggregate recognized media domains into the parent domain?
|
||||
*
|
||||
* Submissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...
|
||||
*
|
||||
* * If `false` then aggregate will occur at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)
|
||||
* * If `true` then then aggregation will occur at the domain level IE youtube.com (5 counts)
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
aggregateMediaDomains?: boolean
|
||||
|
||||
/**
|
||||
* Include reddit `self.*` domains in aggregation?
|
||||
*
|
||||
* Self-posts are aggregated under the domain `self.[subreddit]`. If you wish to include these domains in aggregation set this to `true`
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
includeSelf?: boolean
|
||||
}
|
||||
|
||||
export interface AttributionOptions extends AttributionConfig, RuleOptions {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered
|
||||
*
|
||||
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
|
||||
*
|
||||
* ```
|
||||
* count => Total number of repeat Submissions
|
||||
* threshold => The threshold you configured for this Rule to trigger
|
||||
* url => Url of the submission that triggered the rule
|
||||
* ```
|
||||
* */
|
||||
export interface AttributionJSONConfig extends AttributionConfig, SubmissionRuleJSONConfig {
|
||||
kind: 'attribution'
|
||||
}
|
||||
257
src/Rule/SubmissionRule/RepeatActivityRule.ts
Normal file
257
src/Rule/SubmissionRule/RepeatActivityRule.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import {SubmissionRule, SubmissionRuleJSONConfig} from "./index";
|
||||
import {Rule, RuleOptions, RulePremise, RuleResult} from "../index";
|
||||
import {Comment} from "snoowrap";
|
||||
import {getAuthorActivities, getAuthorComments, getAuthorSubmissions} from "../../Utils/SnoowrapUtils";
|
||||
import {groupBy, parseUsableLinkIdentifier as linkParser, truncateStringToLength} from "../../util";
|
||||
import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../../Common/interfaces";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const parseUsableLinkIdentifier = linkParser();
|
||||
|
||||
interface RepeatActivityData {
|
||||
identifier: string,
|
||||
sets: (Submission | Comment)[]
|
||||
}
|
||||
|
||||
interface RepeatActivityReducer {
|
||||
openSets: RepeatActivityData[]
|
||||
allSets: RepeatActivityData[]
|
||||
}
|
||||
|
||||
const getActivityIdentifier = (activity: (Submission | Comment), length = 200) => {
|
||||
let identifier: string;
|
||||
if (activity instanceof Submission) {
|
||||
if (activity.is_self) {
|
||||
identifier = activity.selftext.slice(0, length);
|
||||
} else {
|
||||
identifier = parseUsableLinkIdentifier(activity.url) as string;
|
||||
}
|
||||
} else {
|
||||
identifier = activity.body.slice(0, length);
|
||||
}
|
||||
return identifier;
|
||||
}
|
||||
|
||||
export class RepeatActivityRule extends SubmissionRule {
|
||||
threshold: number;
|
||||
window: ActivityWindowType;
|
||||
gapAllowance?: number;
|
||||
useSubmissionAsReference: boolean;
|
||||
lookAt: 'submissions' | 'all';
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
|
||||
constructor(options: RepeatActivityOptions) {
|
||||
super(options);
|
||||
const {
|
||||
threshold = 5,
|
||||
window = 15,
|
||||
gapAllowance,
|
||||
useSubmissionAsReference = true,
|
||||
lookAt = 'all',
|
||||
include = [],
|
||||
exclude = []
|
||||
} = options;
|
||||
this.threshold = threshold;
|
||||
this.window = window;
|
||||
this.gapAllowance = gapAllowance;
|
||||
this.useSubmissionAsReference = useSubmissionAsReference;
|
||||
this.include = include;
|
||||
this.exclude = exclude;
|
||||
this.lookAt = lookAt;
|
||||
}
|
||||
|
||||
getKind(): string {
|
||||
return 'Repeat Activity';
|
||||
}
|
||||
|
||||
getSpecificPremise(): object {
|
||||
return {
|
||||
threshold: this.threshold,
|
||||
window: this.window,
|
||||
gapAllowance: this.gapAllowance,
|
||||
useSubmissionAsReference: this.useSubmissionAsReference,
|
||||
include: this.include,
|
||||
exclude: this.exclude,
|
||||
}
|
||||
}
|
||||
|
||||
async process(item: Submission): Promise<[boolean, RuleResult[]]> {
|
||||
const referenceUrl = await item.url;
|
||||
if (referenceUrl === undefined && this.useSubmissionAsReference) {
|
||||
throw new Error(`Cannot run Rule ${this.name} because submission is not a link`);
|
||||
}
|
||||
|
||||
let activities: (Submission | Comment)[] = [];
|
||||
switch (this.lookAt) {
|
||||
case 'submissions':
|
||||
activities = await getAuthorSubmissions(item.author, {window: this.window});
|
||||
break;
|
||||
default:
|
||||
activities = await getAuthorActivities(item.author, {window: this.window});
|
||||
break;
|
||||
}
|
||||
|
||||
const condensedActivities = activities.reduce((acc: RepeatActivityReducer, activity: (Submission | Comment), index: number) => {
|
||||
const {openSets = [], allSets = []} = acc;
|
||||
|
||||
let identifier = getActivityIdentifier(activity);
|
||||
|
||||
let updatedAllSets = [...allSets];
|
||||
let updatedOpenSets = [];
|
||||
let currIdentifierInOpen = false;
|
||||
const bufferedActivities = this.gapAllowance === undefined || this.gapAllowance === 0 ? [] : activities.slice(Math.max(0, index - this.gapAllowance), Math.max(0, index));
|
||||
for (const o of openSets) {
|
||||
if (o.identifier === identifier) {
|
||||
updatedOpenSets.push({...o, sets: [...o.sets, activity]});
|
||||
currIdentifierInOpen = true;
|
||||
} else if (bufferedActivities.some(x => getActivityIdentifier(x) === identifier)) {
|
||||
updatedOpenSets.push(o);
|
||||
} else {
|
||||
updatedAllSets.push(o);
|
||||
}
|
||||
}
|
||||
|
||||
if (!currIdentifierInOpen) {
|
||||
updatedOpenSets.push({identifier, sets: [activity]})
|
||||
}
|
||||
|
||||
return {openSets: updatedOpenSets, allSets: updatedAllSets};
|
||||
|
||||
}, {openSets: [], allSets: []});
|
||||
|
||||
const allRepeatSets = [...condensedActivities.allSets, ...condensedActivities.openSets];
|
||||
|
||||
const identifierGroupedActivities = allRepeatSets.reduce((acc, repeatActivityData) => {
|
||||
let existingSets = [];
|
||||
if (acc.has(repeatActivityData.identifier)) {
|
||||
existingSets = acc.get(repeatActivityData.identifier);
|
||||
}
|
||||
acc.set(repeatActivityData.identifier, [...existingSets, repeatActivityData.sets].sort((a, b) => b.length < a.length ? 1 : -1));
|
||||
return acc;
|
||||
}, new Map());
|
||||
|
||||
let applicableGroupedActivities = identifierGroupedActivities;
|
||||
if (this.useSubmissionAsReference) {
|
||||
applicableGroupedActivities = new Map();
|
||||
const referenceSubmissions = identifierGroupedActivities.get(getActivityIdentifier(item));
|
||||
applicableGroupedActivities.set(getActivityIdentifier(item), referenceSubmissions || [])
|
||||
}
|
||||
|
||||
const identifiersSummary: SummaryData[] = [];
|
||||
for (let [key, value] of applicableGroupedActivities) {
|
||||
const summaryData = {
|
||||
identifier: key,
|
||||
totalSets: value.length,
|
||||
totalTriggeringSets: 0,
|
||||
largestTrigger: 0,
|
||||
triggeringSets: [],
|
||||
triggeringSetsMarkdown: [],
|
||||
};
|
||||
for (let set of value) {
|
||||
if (set.length >= this.threshold) {
|
||||
// @ts-ignore
|
||||
summaryData.triggeringSets.push(set);
|
||||
summaryData.totalTriggeringSets++;
|
||||
summaryData.largestTrigger = Math.max(summaryData.largestTrigger, set.length);
|
||||
const md = set.map((x: (Comment | Submission)) => `[${x instanceof Submission ? x.title : getActivityIdentifier(x, 50)}](https://reddit.com${x.permalink}) in ${x.subreddit_name_prefixed} on ${dayjs(x.created_utc * 1000).utc().format()}`);
|
||||
// @ts-ignore
|
||||
summaryData.triggeringSetsMarkdown.push(md);
|
||||
}
|
||||
}
|
||||
identifiersSummary.push(summaryData);
|
||||
}
|
||||
|
||||
const triggeringSummaries = identifiersSummary.filter(x => x.totalTriggeringSets > 0)
|
||||
if (triggeringSummaries.length > 0) {
|
||||
const largestRepeat = triggeringSummaries.reduce((acc, summ) => Math.max(summ.largestTrigger, acc), 0);
|
||||
const result = `${triggeringSummaries.length} of ${identifiersSummary.length} unique items repeated >=${this.threshold} (threshold) times, largest repeat: ${largestRepeat}`;
|
||||
return Promise.resolve([true, [this.getResult(true, {
|
||||
result,
|
||||
data: {
|
||||
totalTriggeringSets: triggeringSummaries.length,
|
||||
largestRepeat,
|
||||
threshold: this.threshold,
|
||||
gapAllowance: this.gapAllowance,
|
||||
url: referenceUrl,
|
||||
triggeringSummaries,
|
||||
}
|
||||
})]]);
|
||||
}
|
||||
|
||||
return Promise.resolve([false, [this.getResult(false)]]);
|
||||
}
|
||||
}
|
||||
|
||||
interface SummaryData {
|
||||
identifier: string,
|
||||
totalSets: number,
|
||||
totalTriggeringSets: number,
|
||||
largestTrigger: number,
|
||||
triggeringSets: (Comment | Submission)[],
|
||||
triggeringSetsMarkdown: string[]
|
||||
}
|
||||
|
||||
interface RepeatActivityConfig extends ActivityWindow, ReferenceSubmission {
|
||||
/**
|
||||
* The number of repeat submissions that will trigger the rule
|
||||
* @default 5
|
||||
* */
|
||||
threshold?: number,
|
||||
/**
|
||||
* The number of allowed non-identical Submissions between identical Submissions that can be ignored when checking against the threshold value
|
||||
* */
|
||||
gapAllowance?: number,
|
||||
/**
|
||||
* Only include Submissions from this list of Subreddits.
|
||||
*
|
||||
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
|
||||
*
|
||||
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* */
|
||||
include?: string[],
|
||||
/**
|
||||
* Do not include Submissions from this list of Subreddits.
|
||||
*
|
||||
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
|
||||
*
|
||||
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* */
|
||||
exclude?: string[],
|
||||
|
||||
/**
|
||||
* If present determines which activities to consider for gapAllowance.
|
||||
*
|
||||
* * If `submissions` then only the author's submission history is considered IE gapAllowance = 2 ===> can have gap of two submissions between repeats
|
||||
* * If `all` then the author's entire history (submissions/comments) is considered IE gapAllowance = 2 ===> can only have gap of two activities (submissions or comments) between repeats
|
||||
*
|
||||
* @default all
|
||||
* */
|
||||
lookAt?: 'submissions' | 'all',
|
||||
}
|
||||
|
||||
export interface RepeatActivityOptions extends RepeatActivityConfig, RuleOptions {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a user's history for Submissions with identical content
|
||||
*
|
||||
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
|
||||
*
|
||||
* ```
|
||||
* count => Total number of repeat Submissions
|
||||
* threshold => The threshold you configured for this Rule to trigger
|
||||
* url => Url of the submission that triggered the rule
|
||||
* ```
|
||||
* */
|
||||
export interface RepeatActivityJSONConfig extends RepeatActivityConfig, SubmissionRuleJSONConfig {
|
||||
kind: 'repeatActivity'
|
||||
}
|
||||
|
||||
export default RepeatActivityRule;
|
||||
@@ -1,185 +0,0 @@
|
||||
import {SubmissionRule, SubmissionRuleJSONConfig} from "./index";
|
||||
import {Rule, RuleOptions, RulePremise, RuleResult} from "../index";
|
||||
import {Submission} from "snoowrap";
|
||||
import {getAuthorSubmissions} from "../../Utils/SnoowrapUtils";
|
||||
import {groupBy, parseUsableLinkIdentifier as linkParser} from "../../util";
|
||||
import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../../Common/interfaces";
|
||||
|
||||
const groupByUrl = groupBy(['urlIdentifier']);
|
||||
const parseUsableLinkIdentifier = linkParser()
|
||||
|
||||
export class RepeatSubmissionRule extends SubmissionRule {
|
||||
threshold: number;
|
||||
window: ActivityWindowType;
|
||||
gapAllowance?: number;
|
||||
useSubmissionAsReference: boolean;
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
|
||||
constructor(options: RepeatSubmissionOptions) {
|
||||
super(options);
|
||||
const {
|
||||
threshold = 5,
|
||||
window = 15,
|
||||
gapAllowance,
|
||||
useSubmissionAsReference = true,
|
||||
include = [],
|
||||
exclude = []
|
||||
} = options;
|
||||
this.threshold = threshold;
|
||||
this.window = window;
|
||||
this.gapAllowance = gapAllowance;
|
||||
this.useSubmissionAsReference = useSubmissionAsReference;
|
||||
this.include = include;
|
||||
this.exclude = exclude;
|
||||
}
|
||||
|
||||
getKind(): string {
|
||||
return 'Repeat Submission';
|
||||
}
|
||||
|
||||
getSpecificPremise(): object {
|
||||
return {
|
||||
threshold: this.threshold,
|
||||
window: this.window,
|
||||
gapAllowance: this.gapAllowance,
|
||||
useSubmissionAsReference: this.useSubmissionAsReference,
|
||||
include: this.include,
|
||||
exclude: this.exclude,
|
||||
}
|
||||
}
|
||||
|
||||
async process(item: Submission): Promise<[boolean, RuleResult[]]> {
|
||||
const referenceUrl = await item.url;
|
||||
if (referenceUrl === undefined && this.useSubmissionAsReference) {
|
||||
throw new Error(`Cannot run Rule ${this.name} because submission is not a link`);
|
||||
}
|
||||
const submissions = await getAuthorSubmissions(item.author, {window: this.window});
|
||||
|
||||
// we need to check in order
|
||||
if (this.gapAllowance !== undefined) {
|
||||
let consecutivePosts = referenceUrl !== undefined ? 1 : 0;
|
||||
let gap = 0;
|
||||
let lastUrl = parseUsableLinkIdentifier(referenceUrl);
|
||||
// start with second post since first is the one we triggered on (prob)
|
||||
for (const sub of submissions.slice(1)) {
|
||||
if (sub.url !== undefined) {
|
||||
const regUrl = parseUsableLinkIdentifier(sub.url);
|
||||
if (lastUrl === undefined || lastUrl === regUrl) {
|
||||
consecutivePosts++;
|
||||
gap = 0;
|
||||
} else {
|
||||
gap++;
|
||||
if (gap > this.gapAllowance) {
|
||||
gap = 0;
|
||||
consecutivePosts = 1;
|
||||
}
|
||||
}
|
||||
lastUrl = regUrl;
|
||||
} else {
|
||||
gap++;
|
||||
if (gap > this.gapAllowance) {
|
||||
gap = 0;
|
||||
consecutivePosts = 0;
|
||||
}
|
||||
}
|
||||
if (consecutivePosts >= this.threshold) {
|
||||
const result = `Threshold of ${this.threshold} repeats triggered for submission with url ${sub.url}`;
|
||||
this.logger.debug(result);
|
||||
return Promise.resolve([true, [this.getResult(true, {
|
||||
result,
|
||||
data: {
|
||||
count: consecutivePosts,
|
||||
threshold: this.threshold,
|
||||
url: sub.url,
|
||||
}
|
||||
})]]);
|
||||
}
|
||||
}
|
||||
return Promise.resolve([false, [this.getResult(false)]]);
|
||||
}
|
||||
|
||||
// otherwise we can just group all occurrences together
|
||||
const groupedPosts = groupByUrl(submissions.map(x => ({
|
||||
...x,
|
||||
urlIdentifier: parseUsableLinkIdentifier(x.url)
|
||||
})));
|
||||
let groupsToCheck = [];
|
||||
if (this.useSubmissionAsReference) {
|
||||
const identifier = parseUsableLinkIdentifier(referenceUrl);
|
||||
const {[identifier as string]: refGroup = []} = groupedPosts;
|
||||
groupsToCheck.push(refGroup);
|
||||
} else {
|
||||
groupsToCheck = Object.values(groupedPosts)
|
||||
}
|
||||
for (const group of groupsToCheck) {
|
||||
if (group.length >= this.threshold) {
|
||||
// @ts-ignore
|
||||
const result = `Threshold of ${this.threshold} repeats triggered for submission with url ${group[0].url}`;
|
||||
this.logger.debug(result);
|
||||
return Promise.resolve([true, [this.getResult(true, {
|
||||
result,
|
||||
data: {
|
||||
count: group.length,
|
||||
threshold: this.threshold,
|
||||
// @ts-ignore
|
||||
url: group[0].url,
|
||||
}
|
||||
})]]);
|
||||
}
|
||||
}
|
||||
return Promise.resolve([false, [this.getResult(false)]]);
|
||||
}
|
||||
}
|
||||
|
||||
interface RepeatSubmissionConfig extends ActivityWindow, ReferenceSubmission {
|
||||
/**
|
||||
* The number of repeat submissions that will trigger the rule
|
||||
* @default 5
|
||||
* */
|
||||
threshold?: number,
|
||||
/**
|
||||
* The number of allowed non-identical Submissions between identical Submissions that can be ignored when checking against the threshold value
|
||||
* */
|
||||
gapAllowance?: number,
|
||||
/**
|
||||
* Only include Submissions from this list of Subreddits.
|
||||
*
|
||||
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
|
||||
*
|
||||
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* */
|
||||
include?: string[],
|
||||
/**
|
||||
* Do not include Submissions from this list of Subreddits.
|
||||
*
|
||||
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
|
||||
*
|
||||
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* */
|
||||
exclude?: string[],
|
||||
}
|
||||
|
||||
export interface RepeatSubmissionOptions extends RepeatSubmissionConfig, RuleOptions {
|
||||
|
||||
}
|
||||
/**
|
||||
* Checks a user's history for Submissions with identical content
|
||||
*
|
||||
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
|
||||
*
|
||||
* ```
|
||||
* count => Total number of repeat Submissions
|
||||
* threshold => The threshold you configured for this Rule to trigger
|
||||
* url => Url of the submission that triggered the rule
|
||||
* ```
|
||||
* */
|
||||
export interface RepeatSubmissionJSONConfig extends RepeatSubmissionConfig, SubmissionRuleJSONConfig {
|
||||
kind: 'repeatSubmission'
|
||||
}
|
||||
|
||||
export default RepeatSubmissionRule;
|
||||
@@ -64,9 +64,10 @@ export abstract class Rule implements IRule, Triggerable {
|
||||
|
||||
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[(boolean | null), RuleResult[]]> {
|
||||
this.logger = this.logger.child(loggerMetaShuffle(this.logger, `${item instanceof Submission ? 'SUB' : 'COMM'} ${item.id}`), mergeArr);
|
||||
this.logger.debug('Starting rule run');
|
||||
this.logger.debug('Starting');
|
||||
const existingResult = findResultByPremise(this.getPremise(), existingResults);
|
||||
if (existingResult) {
|
||||
this.logger.debug('Returning existing result');
|
||||
return Promise.resolve([existingResult.triggered, [{...existingResult, name: this.name}]]);
|
||||
}
|
||||
if (this.authors.include !== undefined && this.authors.include.length > 0) {
|
||||
@@ -194,11 +195,10 @@ export interface IRule {
|
||||
authors?: AuthorOptions
|
||||
}
|
||||
|
||||
/** @see {isRuleConfig} ts-auto-guard:type-guard */
|
||||
export interface RuleJSONConfig extends IRule {
|
||||
/**
|
||||
* The kind of rule to run
|
||||
*/
|
||||
kind: 'recentActivity' | 'repeatSubmission' | 'author'
|
||||
kind: 'recentActivity' | 'repeatActivity' | 'author' | 'attribution'
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,167 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AttributionCriteria": {
|
||||
"properties": {
|
||||
"minActivityCount": {
|
||||
"default": 5,
|
||||
"description": "The minimum number of activities (activities defined in `includeInTotal`) that must exist for this criteria to run",
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"threshold": {
|
||||
"default": "10%",
|
||||
"description": "The number or percentage to trigger this rule at\n\n* If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger",
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"thresholdOn": {
|
||||
"default": "all",
|
||||
"description": "What activities to use for total count when determining what percentage an attribution comprises\n\nEX:\n\nAuthor has 100 activities, 40 are submissions and 60 are comments\n\n* If `submission` then if 10 submission are for Youtube Channel A then percentage => 10/40 = 25%\n* If `all` then if 10 submission are for Youtube Channel A then percentage => 10/100 = 10%",
|
||||
"enum": [
|
||||
"all",
|
||||
"submissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ActivityWindowCriteria"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"threshold",
|
||||
"window",
|
||||
"thresholdOn",
|
||||
"minActivityCount",
|
||||
"name"
|
||||
],
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AttributionJSONConfig": {
|
||||
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
|
||||
"properties": {
|
||||
"aggregateMediaDomains": {
|
||||
"default": false,
|
||||
"description": "Should the rule aggregate recognized media domains into the parent domain?\n\nSubmissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...\n\n* If `false` then aggregate will occur at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then aggregation will occur at the domain level IE youtube.com (5 counts)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"authors": {
|
||||
"$ref": "#/definitions/AuthorOptions",
|
||||
"additionalProperties": false,
|
||||
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
|
||||
"minProperties": 1
|
||||
},
|
||||
"criteria": {
|
||||
"description": "A list threshold-window values to test attribution against\n\nIf none is provided the default set used is:\n\n```\nthreshold: 10%\nwindow: 100\n```",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AttributionCriteria"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"criteriaJoin": {
|
||||
"description": "* If `OR` then any set of AttributionCriteria that produce an Attribution over the threshold will trigger the rule.\n* If `AND` then all AttributionCriteria sets must product an Attribution over the threshold to trigger the rule.",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"includeSelf": {
|
||||
"default": false,
|
||||
"description": "Include reddit `self.*` domains in aggregation?\n\nSelf-posts are aggregated under the domain `self.[subreddit]`. If you wish to include these domains in aggregation set this to `true`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"attribution"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lookAt": {
|
||||
"default": "all",
|
||||
"description": "Determines which type of attribution to look at\n\n* If `media` then only the author's submission history which reddit recognizes as media (youtube, vimeo, etc.) will be considered\n* If `all` then all domains (EX youtube.com, twitter.com) from the author's submission history will be considered",
|
||||
"enum": [
|
||||
"all",
|
||||
"media"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"useSubmissionAsReference": {
|
||||
"default": true,
|
||||
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"kind",
|
||||
"criteria",
|
||||
"criteriaJoin",
|
||||
"include",
|
||||
"exclude",
|
||||
"lookAt",
|
||||
"aggregateMediaDomains",
|
||||
"includeSelf",
|
||||
"useSubmissionAsReference",
|
||||
"name",
|
||||
"authors"
|
||||
],
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AuthorCriteria": {
|
||||
"additionalProperties": false,
|
||||
"description": "Criteria with which to test against the author of an Activity. The outcome of the test is based on:\n\n1. All present properties passing and\n2. If a property is a list then any value from the list matching",
|
||||
@@ -152,27 +313,29 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CheckJSONConfig": {
|
||||
"description": "An object consisting of Rules (tests) and Actions to perform if Rules are triggered",
|
||||
"CheckJson": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
"description": "The actions to run after the check is successfully triggered. ALL actions will run in the order they are listed",
|
||||
"description": "The `Actions` to run after the check is successfully triggered. ALL `Actions` will run in the order they are listed\n\n Can be `Action` or the `name` of any **named** `Action` in your subreddit's configuration",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/FlairActionJSONConfig"
|
||||
"$ref": "#/definitions/FlairActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CommentActionJSONConfig"
|
||||
"$ref": "#/definitions/CommentActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/LockActionJSONConfig"
|
||||
"$ref": "#/definitions/ReportActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RemoveActionJSONConfig"
|
||||
"$ref": "#/definitions/LockActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ReportActionJSONConfig"
|
||||
"$ref": "#/definitions/RemoveActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -181,7 +344,7 @@
|
||||
},
|
||||
"condition": {
|
||||
"default": "AND",
|
||||
"description": "Under what condition should a set of rules be considered \"successful\"?\n\nIf \"OR\" then ANY triggered rule results in success.\n\nIf \"AND\" then ALL rules must be triggered to result in success.",
|
||||
"description": "Under what condition should a set of run `Rule` objects be considered \"successful\"?\n\nIf `OR` then **any** triggered `Rule` object results in success.\n\nIf `AND` then **all** `Rule` objects must be triggered to result in success.",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
@@ -205,20 +368,26 @@
|
||||
"type": "string"
|
||||
},
|
||||
"rules": {
|
||||
"description": "Rules are run in the order found in configuration. Can be Rules or RuleSets",
|
||||
"description": "A list of Rules to run. If `Rule` objects are triggered based on `condition` then `Actions` will be performed.\n\nCan be `Rule`, `RuleSet`, or the `name` of any **named** `Rule` in your subreddit's configuration",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RecentActivityRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RepeatSubmissionJSONConfig"
|
||||
"$ref": "#/definitions/RepeatActivityJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AuthorRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetJSONConfig"
|
||||
"$ref": "#/definitions/AttributionJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -242,7 +411,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"CommentActionJSONConfig": {
|
||||
"CommentActionJson": {
|
||||
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
|
||||
"properties": {
|
||||
"content": {
|
||||
@@ -335,7 +504,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FlairActionJSONConfig": {
|
||||
"FlairActionJson": {
|
||||
"description": "Flair the Submission",
|
||||
"properties": {
|
||||
"css": {
|
||||
@@ -374,7 +543,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"LockActionJSONConfig": {
|
||||
"LockActionJson": {
|
||||
"description": "Lock the Activity",
|
||||
"properties": {
|
||||
"kind": {
|
||||
@@ -547,7 +716,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RemoveActionJSONConfig": {
|
||||
"RemoveActionJson": {
|
||||
"description": "Remove the Activity",
|
||||
"properties": {
|
||||
"kind": {
|
||||
@@ -576,7 +745,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepeatSubmissionJSONConfig": {
|
||||
"RepeatActivityJSONConfig": {
|
||||
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
|
||||
"properties": {
|
||||
"authors": {
|
||||
@@ -616,7 +785,16 @@
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"repeatSubmission"
|
||||
"repeatActivity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lookAt": {
|
||||
"default": "all",
|
||||
"description": "If present determines which activities to consider for gapAllowance.\n\n* If `submissions` then only the author's submission history is considered IE gapAllowance = 2 ===> can have gap of two submissions between repeats\n* If `all` then the author's entire history (submissions/comments) is considered IE gapAllowance = 2 ===> can only have gap of two activities (submissions or comments) between repeats",
|
||||
"enum": [
|
||||
"all",
|
||||
"submissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -678,6 +856,7 @@
|
||||
"gapAllowance",
|
||||
"include",
|
||||
"exclude",
|
||||
"lookAt",
|
||||
"window",
|
||||
"useSubmissionAsReference",
|
||||
"name",
|
||||
@@ -688,7 +867,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ReportActionJSONConfig": {
|
||||
"ReportActionJson": {
|
||||
"description": "Report the Activity",
|
||||
"properties": {
|
||||
"content": {
|
||||
@@ -723,12 +902,12 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RuleSetJSONConfig": {
|
||||
"description": "A RuleSet is a \"nested\" set of Rules that can be used to create more complex AND/OR behavior. Think of the outcome of a RuleSet as the result of all of it's Rules (based on condition)",
|
||||
"RuleSetJson": {
|
||||
"description": "A RuleSet is a \"nested\" set of `Rule` objects that can be used to create more complex AND/OR behavior. Think of the outcome of a `RuleSet` as the result of all of its run `Rule` objects (based on `condition`)",
|
||||
"properties": {
|
||||
"condition": {
|
||||
"default": "AND",
|
||||
"description": "Under what condition should a set of rules be considered \"successful\"?\n\nIf \"OR\" then ANY triggered rule results in success.\n\nIf \"AND\" then ALL rules must be triggered to result in success.",
|
||||
"description": "Under what condition should a set of run `Rule` objects be considered \"successful\"?\n\nIf `OR` then **any** triggered `Rule` object results in success.\n\nIf `AND` then **all** `Rule` objects must be triggered to result in success.",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
@@ -736,16 +915,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"rules": {
|
||||
"description": "Can be `Rule` or the `name` of any **named** `Rule` in your subreddit's configuration",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RecentActivityRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RepeatSubmissionJSONConfig"
|
||||
"$ref": "#/definitions/RepeatActivityJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AuthorRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AttributionJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -794,14 +980,23 @@
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"apiLimitWarning": {
|
||||
"default": 250,
|
||||
"description": "When Reddit API limit remaining reaches this number context bot will start warning on every poll interval",
|
||||
"type": "number"
|
||||
},
|
||||
"checks": {
|
||||
"description": "A list of all the checks that should be run for a subreddit.\n\nChecks are split into two lists -- submission or comment -- based on kind and run independently.\n\nChecks in each list are run in the order found in the configuration.\n\nWhen a check \"passes\", and actions are performed, then all subsequent checks are skipped.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/CheckJSONConfig"
|
||||
"$ref": "#/definitions/CheckJson"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"heartbeatInterval": {
|
||||
"description": "If present, time in milliseconds between HEARTBEAT log statements with current api limit count. Nice to have to know things are still ticking if there is low activity",
|
||||
"type": "number"
|
||||
},
|
||||
"polling": {
|
||||
"$ref": "#/definitions/PollingOptions",
|
||||
"description": "You may specify polling options independently for submissions/comments"
|
||||
@@ -809,7 +1004,9 @@
|
||||
},
|
||||
"propertyOrder": [
|
||||
"checks",
|
||||
"polling"
|
||||
"polling",
|
||||
"heartbeatInterval",
|
||||
"apiLimitWarning"
|
||||
],
|
||||
"required": [
|
||||
"checks"
|
||||
|
||||
@@ -1,6 +1,217 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RecentActivityRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RepeatActivityJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AuthorRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AttributionJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"ActivityWindowCriteria": {
|
||||
"additionalProperties": false,
|
||||
"description": "If both properties are defined then the first criteria met will be used IE if # of activities = count before duration is reached then count will be used, or vice versa",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"count": {
|
||||
"description": "The number of activities (submission/comments) to consider",
|
||||
"type": "number"
|
||||
},
|
||||
"duration": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "An ISO 8601 duration or Day.js duration object.\n\nThe duration will be subtracted from the time when the rule is run to create a time range like this:\n\nendTime = NOW <----> startTime = (NOW - duration)\n\nEX endTime = 3:00PM <----> startTime = (NOW - 15 minutes) = 2:45PM -- so look for activities between 2:45PM and 3:00PM",
|
||||
"examples": [
|
||||
"PT1M",
|
||||
{
|
||||
"minutes": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"count",
|
||||
"duration"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AttributionCriteria": {
|
||||
"properties": {
|
||||
"minActivityCount": {
|
||||
"default": 5,
|
||||
"description": "The minimum number of activities (activities defined in `includeInTotal`) that must exist for this criteria to run",
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"threshold": {
|
||||
"default": "10%",
|
||||
"description": "The number or percentage to trigger this rule at\n\n* If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger",
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"thresholdOn": {
|
||||
"default": "all",
|
||||
"description": "What activities to use for total count when determining what percentage an attribution comprises\n\nEX:\n\nAuthor has 100 activities, 40 are submissions and 60 are comments\n\n* If `submission` then if 10 submission are for Youtube Channel A then percentage => 10/40 = 25%\n* If `all` then if 10 submission are for Youtube Channel A then percentage => 10/100 = 10%",
|
||||
"enum": [
|
||||
"all",
|
||||
"submissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ActivityWindowCriteria"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"threshold",
|
||||
"window",
|
||||
"thresholdOn",
|
||||
"minActivityCount",
|
||||
"name"
|
||||
],
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AttributionJSONConfig": {
|
||||
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
|
||||
"properties": {
|
||||
"aggregateMediaDomains": {
|
||||
"default": false,
|
||||
"description": "Should the rule aggregate recognized media domains into the parent domain?\n\nSubmissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...\n\n* If `false` then aggregate will occur at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then aggregation will occur at the domain level IE youtube.com (5 counts)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"authors": {
|
||||
"$ref": "#/definitions/AuthorOptions",
|
||||
"additionalProperties": false,
|
||||
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
|
||||
"minProperties": 1
|
||||
},
|
||||
"criteria": {
|
||||
"description": "A list threshold-window values to test attribution against\n\nIf none is provided the default set used is:\n\n```\nthreshold: 10%\nwindow: 100\n```",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AttributionCriteria"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"criteriaJoin": {
|
||||
"description": "* If `OR` then any set of AttributionCriteria that produce an Attribution over the threshold will trigger the rule.\n* If `AND` then all AttributionCriteria sets must product an Attribution over the threshold to trigger the rule.",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"includeSelf": {
|
||||
"default": false,
|
||||
"description": "Include reddit `self.*` domains in aggregation?\n\nSelf-posts are aggregated under the domain `self.[subreddit]`. If you wish to include these domains in aggregation set this to `true`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"attribution"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lookAt": {
|
||||
"default": "all",
|
||||
"description": "Determines which type of attribution to look at\n\n* If `media` then only the author's submission history which reddit recognizes as media (youtube, vimeo, etc.) will be considered\n* If `all` then all domains (EX youtube.com, twitter.com) from the author's submission history will be considered",
|
||||
"enum": [
|
||||
"all",
|
||||
"media"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"useSubmissionAsReference": {
|
||||
"default": true,
|
||||
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"kind",
|
||||
"criteria",
|
||||
"criteriaJoin",
|
||||
"include",
|
||||
"exclude",
|
||||
"lookAt",
|
||||
"aggregateMediaDomains",
|
||||
"includeSelf",
|
||||
"useSubmissionAsReference",
|
||||
"name",
|
||||
"authors"
|
||||
],
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AuthorCriteria": {
|
||||
"additionalProperties": false,
|
||||
"description": "Criteria with which to test against the author of an Activity. The outcome of the test is based on:\n\n1. All present properties passing and\n2. If a property is a list then any value from the list matching",
|
||||
@@ -69,38 +280,340 @@
|
||||
"exclude"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"authors": {
|
||||
"$ref": "#/definitions/AuthorOptions",
|
||||
"additionalProperties": false,
|
||||
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
|
||||
"minProperties": 1
|
||||
},
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"author",
|
||||
"recentActivity",
|
||||
"repeatSubmission"
|
||||
"AuthorRuleJSONConfig": {
|
||||
"properties": {
|
||||
"authors": {
|
||||
"$ref": "#/definitions/AuthorOptions",
|
||||
"additionalProperties": false,
|
||||
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
|
||||
"minProperties": 1
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Only runs if include is not present. Will \"pass\" if any of set of the AuthorCriteria does not pass",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AuthorCriteria"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Will \"pass\" if any set of AuthorCriteria passes",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AuthorCriteria"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"author"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"kind",
|
||||
"include",
|
||||
"exclude",
|
||||
"name",
|
||||
"authors"
|
||||
],
|
||||
"type": "string"
|
||||
"required": [
|
||||
"exclude",
|
||||
"include",
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
"DurationObject": {
|
||||
"additionalProperties": false,
|
||||
"description": "A Day.js duration object\n\nhttps://day.js.org/docs/en/durations/creating",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"days": {
|
||||
"type": "number"
|
||||
},
|
||||
"hours": {
|
||||
"type": "number"
|
||||
},
|
||||
"minutes": {
|
||||
"type": "number"
|
||||
},
|
||||
"months": {
|
||||
"type": "number"
|
||||
},
|
||||
"seconds": {
|
||||
"type": "number"
|
||||
},
|
||||
"weeks": {
|
||||
"type": "number"
|
||||
},
|
||||
"years": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"seconds",
|
||||
"minutes",
|
||||
"hours",
|
||||
"days",
|
||||
"weeks",
|
||||
"months",
|
||||
"years"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RecentActivityRuleJSONConfig": {
|
||||
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
|
||||
"properties": {
|
||||
"authors": {
|
||||
"$ref": "#/definitions/AuthorOptions",
|
||||
"additionalProperties": false,
|
||||
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
|
||||
"minProperties": 1
|
||||
},
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"recentActivity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lookAt": {
|
||||
"description": "If present restricts the activities that are considered for count from SubThreshold",
|
||||
"enum": [
|
||||
"comments",
|
||||
"submissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"thresholds": {
|
||||
"description": "A list of subreddits/count criteria that may trigger this rule. ANY SubThreshold will trigger this rule.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SubThreshold"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"useSubmissionAsReference": {
|
||||
"default": true,
|
||||
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ActivityWindowCriteria"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"default": 15,
|
||||
"description": "Criteria for defining what set of activities should be considered.\n\nThe value of this property may be either count OR duration -- to use both write it as an ActivityWindowCriteria\n\nSee ActivityWindowCriteria for descriptions of what count/duration do",
|
||||
"examples": [
|
||||
15,
|
||||
"PT1M",
|
||||
{
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"duration": {
|
||||
"hours": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"count": 5,
|
||||
"duration": {
|
||||
"minutes": 15
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"kind",
|
||||
"lookAt",
|
||||
"thresholds",
|
||||
"window",
|
||||
"useSubmissionAsReference",
|
||||
"name",
|
||||
"authors"
|
||||
],
|
||||
"required": [
|
||||
"kind",
|
||||
"thresholds"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepeatActivityJSONConfig": {
|
||||
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
|
||||
"properties": {
|
||||
"authors": {
|
||||
"$ref": "#/definitions/AuthorOptions",
|
||||
"additionalProperties": false,
|
||||
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
|
||||
"minProperties": 1
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"gapAllowance": {
|
||||
"description": "The number of allowed non-identical Submissions between identical Submissions that can be ignored when checking against the threshold value",
|
||||
"type": "number"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"repeatActivity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lookAt": {
|
||||
"default": "all",
|
||||
"description": "If present determines which activities to consider for gapAllowance.\n\n* If `submissions` then only the author's submission history is considered IE gapAllowance = 2 ===> can have gap of two submissions between repeats\n* If `all` then the author's entire history (submissions/comments) is considered IE gapAllowance = 2 ===> can only have gap of two activities (submissions or comments) between repeats",
|
||||
"enum": [
|
||||
"all",
|
||||
"submissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"threshold": {
|
||||
"default": 5,
|
||||
"description": "The number of repeat submissions that will trigger the rule",
|
||||
"type": "number"
|
||||
},
|
||||
"useSubmissionAsReference": {
|
||||
"default": true,
|
||||
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ActivityWindowCriteria"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"default": 15,
|
||||
"description": "Criteria for defining what set of activities should be considered.\n\nThe value of this property may be either count OR duration -- to use both write it as an ActivityWindowCriteria\n\nSee ActivityWindowCriteria for descriptions of what count/duration do",
|
||||
"examples": [
|
||||
15,
|
||||
"PT1M",
|
||||
{
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"duration": {
|
||||
"hours": 5
|
||||
}
|
||||
},
|
||||
{
|
||||
"count": 5,
|
||||
"duration": {
|
||||
"minutes": 15
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"kind",
|
||||
"threshold",
|
||||
"gapAllowance",
|
||||
"include",
|
||||
"exclude",
|
||||
"lookAt",
|
||||
"window",
|
||||
"useSubmissionAsReference",
|
||||
"name",
|
||||
"authors"
|
||||
],
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubThreshold": {
|
||||
"properties": {
|
||||
"count": {
|
||||
"default": 1,
|
||||
"description": "The number of activities in each subreddit from the list that will trigger this rule",
|
||||
"minimum": 1,
|
||||
"type": "number"
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "A list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"count",
|
||||
"subreddits"
|
||||
],
|
||||
"required": [
|
||||
"subreddits"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"kind",
|
||||
"name",
|
||||
"authors"
|
||||
],
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,167 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AttributionCriteria": {
|
||||
"properties": {
|
||||
"minActivityCount": {
|
||||
"default": 5,
|
||||
"description": "The minimum number of activities (activities defined in `includeInTotal`) that must exist for this criteria to run",
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"threshold": {
|
||||
"default": "10%",
|
||||
"description": "The number or percentage to trigger this rule at\n\n* If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger",
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
},
|
||||
"thresholdOn": {
|
||||
"default": "all",
|
||||
"description": "What activities to use for total count when determining what percentage an attribution comprises\n\nEX:\n\nAuthor has 100 activities, 40 are submissions and 60 are comments\n\n* If `submission` then if 10 submission are for Youtube Channel A then percentage => 10/40 = 25%\n* If `all` then if 10 submission are for Youtube Channel A then percentage => 10/100 = 10%",
|
||||
"enum": [
|
||||
"all",
|
||||
"submissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ActivityWindowCriteria"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"threshold",
|
||||
"window",
|
||||
"thresholdOn",
|
||||
"minActivityCount",
|
||||
"name"
|
||||
],
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AttributionJSONConfig": {
|
||||
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
|
||||
"properties": {
|
||||
"aggregateMediaDomains": {
|
||||
"default": false,
|
||||
"description": "Should the rule aggregate recognized media domains into the parent domain?\n\nSubmissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...\n\n* If `false` then aggregate will occur at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then aggregation will occur at the domain level IE youtube.com (5 counts)",
|
||||
"type": "boolean"
|
||||
},
|
||||
"authors": {
|
||||
"$ref": "#/definitions/AuthorOptions",
|
||||
"additionalProperties": false,
|
||||
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
|
||||
"minProperties": 1
|
||||
},
|
||||
"criteria": {
|
||||
"description": "A list threshold-window values to test attribution against\n\nIf none is provided the default set used is:\n\n```\nthreshold: 10%\nwindow: 100\n```",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AttributionCriteria"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"criteriaJoin": {
|
||||
"description": "* If `OR` then any set of AttributionCriteria that produce an Attribution over the threshold will trigger the rule.\n* If `AND` then all AttributionCriteria sets must product an Attribution over the threshold to trigger the rule.",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits.\n\nA list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"includeSelf": {
|
||||
"default": false,
|
||||
"description": "Include reddit `self.*` domains in aggregation?\n\nSelf-posts are aggregated under the domain `self.[subreddit]`. If you wish to include these domains in aggregation set this to `true`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"attribution"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lookAt": {
|
||||
"default": "all",
|
||||
"description": "Determines which type of attribution to look at\n\n* If `media` then only the author's submission history which reddit recognizes as media (youtube, vimeo, etc.) will be considered\n* If `all` then all domains (EX youtube.com, twitter.com) from the author's submission history will be considered",
|
||||
"enum": [
|
||||
"all",
|
||||
"media"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"useSubmissionAsReference": {
|
||||
"default": true,
|
||||
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"propertyOrder": [
|
||||
"kind",
|
||||
"criteria",
|
||||
"criteriaJoin",
|
||||
"include",
|
||||
"exclude",
|
||||
"lookAt",
|
||||
"aggregateMediaDomains",
|
||||
"includeSelf",
|
||||
"useSubmissionAsReference",
|
||||
"name",
|
||||
"authors"
|
||||
],
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"AuthorCriteria": {
|
||||
"additionalProperties": false,
|
||||
"description": "Criteria with which to test against the author of an Activity. The outcome of the test is based on:\n\n1. All present properties passing and\n2. If a property is a list then any value from the list matching",
|
||||
@@ -284,7 +445,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RepeatSubmissionJSONConfig": {
|
||||
"RepeatActivityJSONConfig": {
|
||||
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
|
||||
"properties": {
|
||||
"authors": {
|
||||
@@ -324,7 +485,16 @@
|
||||
"kind": {
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"repeatSubmission"
|
||||
"repeatActivity"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lookAt": {
|
||||
"default": "all",
|
||||
"description": "If present determines which activities to consider for gapAllowance.\n\n* If `submissions` then only the author's submission history is considered IE gapAllowance = 2 ===> can have gap of two submissions between repeats\n* If `all` then the author's entire history (submissions/comments) is considered IE gapAllowance = 2 ===> can only have gap of two activities (submissions or comments) between repeats",
|
||||
"enum": [
|
||||
"all",
|
||||
"submissions"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
@@ -386,6 +556,7 @@
|
||||
"gapAllowance",
|
||||
"include",
|
||||
"exclude",
|
||||
"lookAt",
|
||||
"window",
|
||||
"useSubmissionAsReference",
|
||||
"name",
|
||||
@@ -427,11 +598,11 @@
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "A RuleSet is a \"nested\" set of Rules that can be used to create more complex AND/OR behavior. Think of the outcome of a RuleSet as the result of all of it's Rules (based on condition)",
|
||||
"description": "A RuleSet is a \"nested\" set of `Rule` objects that can be used to create more complex AND/OR behavior. Think of the outcome of a `RuleSet` as the result of all of its run `Rule` objects (based on `condition`)",
|
||||
"properties": {
|
||||
"condition": {
|
||||
"default": "AND",
|
||||
"description": "Under what condition should a set of rules be considered \"successful\"?\n\nIf \"OR\" then ANY triggered rule results in success.\n\nIf \"AND\" then ALL rules must be triggered to result in success.",
|
||||
"description": "Under what condition should a set of run `Rule` objects be considered \"successful\"?\n\nIf `OR` then **any** triggered `Rule` object results in success.\n\nIf `AND` then **all** `Rule` objects must be triggered to result in success.",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
@@ -439,16 +610,23 @@
|
||||
"type": "string"
|
||||
},
|
||||
"rules": {
|
||||
"description": "Can be `Rule` or the `name` of any **named** `Rule` in your subreddit's configuration",
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RecentActivityRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RepeatSubmissionJSONConfig"
|
||||
"$ref": "#/definitions/RepeatActivityJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AuthorRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/AttributionJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -11,13 +11,10 @@ import {CommentStream, SubmissionStream} from "snoostorm";
|
||||
import pEvent from "p-event";
|
||||
import {RuleResult} from "../Rule";
|
||||
import {ConfigBuilder} from "../ConfigBuilder";
|
||||
import {PollingOptions} from "../Common/interfaces";
|
||||
import {ManagerOptions, PollingOptions} from "../Common/interfaces";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {itemContentPeek} from "../Utils/SnoowrapUtils";
|
||||
|
||||
export interface ManagerOptions {
|
||||
polling?: PollingOptions
|
||||
}
|
||||
import dayjs from "dayjs";
|
||||
|
||||
export class Manager {
|
||||
subreddit: Subreddit;
|
||||
@@ -31,17 +28,28 @@ export class Manager {
|
||||
streamSub?: SubmissionStream;
|
||||
commentsListedOnce = false;
|
||||
streamComments?: CommentStream;
|
||||
heartbeatInterval?: number;
|
||||
lastHeartbeat = dayjs();
|
||||
apiLimitWarning: number;
|
||||
|
||||
constructor(sub: Subreddit, client: Snoowrap, logger: Logger, sourceData: object, opts: ManagerOptions = {}) {
|
||||
this.logger = logger.child(loggerMetaShuffle(logger, undefined, [`r/${sub.display_name}`], {truncateLength: 40}), mergeArr);
|
||||
|
||||
const configBuilder = new ConfigBuilder({logger: this.logger});
|
||||
const [subChecks, commentChecks, configManagerOptions] = configBuilder.buildFromJson(sourceData);
|
||||
const {polling = {}} = configManagerOptions || {};
|
||||
const {polling = {}, heartbeatInterval, apiLimitWarning = 250} = configManagerOptions || {};
|
||||
this.pollOptions = {...polling, ...opts.polling};
|
||||
this.heartbeatInterval = heartbeatInterval;
|
||||
this.apiLimitWarning = apiLimitWarning;
|
||||
this.subreddit = sub;
|
||||
this.client = client;
|
||||
for(const sub of subChecks) {
|
||||
this.logger.info(`Submission Check: ${sub.name}${sub.description !== undefined ? ` ${sub.description}` : ''}`);
|
||||
}
|
||||
this.submissionChecks = subChecks;
|
||||
for(const comm of commentChecks) {
|
||||
this.logger.info(`Comment Check: ${comm.name}${comm.description !== undefined ? ` ${comm.description}` : ''}`);
|
||||
}
|
||||
this.commentChecks = commentChecks;
|
||||
const checkSummary = `Found Checks -- Submission: ${this.submissionChecks.length} | Comment: ${this.commentChecks.length}`;
|
||||
if (subChecks.length === 0 && commentChecks.length === 0) {
|
||||
@@ -51,16 +59,20 @@ export class Manager {
|
||||
}
|
||||
}
|
||||
|
||||
async runChecks(checkType: ('Comment' | 'Submission'), item: (Submission | Comment)): Promise<void> {
|
||||
async runChecks(checkType: ('Comment' | 'Submission'), item: (Submission | Comment), checkNames: string[] = []): Promise<void> {
|
||||
const checks = checkType === 'Comment' ? this.commentChecks : this.submissionChecks;
|
||||
const itemId = await item.id;
|
||||
let allRuleResults: RuleResult[] = [];
|
||||
const itemIdentifier = `${checkType} ${itemId}`;
|
||||
const [peek, _] = await itemContentPeek(item);
|
||||
this.logger.debug(`New Event: ${itemIdentifier} => ${peek}`);
|
||||
this.logger.info(`New Event: ${itemIdentifier} => ${peek}`);
|
||||
|
||||
for (const check of checks) {
|
||||
this.logger.debug(`Running Check ${check.name} on ${itemIdentifier}`);
|
||||
if(checkNames.length > 0 && !checkNames.map(x => x.toLowerCase()).some(x => x === check.name.toLowerCase())) {
|
||||
this.logger.debug(`Check ${check} not in array of requested checks to run, skipping`);
|
||||
continue;
|
||||
}
|
||||
this.logger.debug(`[${itemIdentifier}] Running Check ${check.name}`);
|
||||
let triggered = false;
|
||||
let currentResults: RuleResult[] = [];
|
||||
try {
|
||||
@@ -70,24 +82,36 @@ export class Manager {
|
||||
triggered = checkTriggered;
|
||||
const invokedRules = checkResults.map(x => x.name || x.premise.kind).join(' | ');
|
||||
if (checkTriggered) {
|
||||
this.logger.debug(`Check ${check.name} was triggered with invoked Rules: ${invokedRules}`);
|
||||
this.logger.info(`[${itemIdentifier}] [CHK ${check.name}] Triggered with invoked Rules: ${invokedRules}`);
|
||||
} else {
|
||||
this.logger.debug(`Check ${check.name} was not triggered using invoked Rule(s): ${invokedRules}`);
|
||||
this.logger.debug(`[${itemIdentifier}] [CHK ${check.name}] WAS NOT triggered with invoked Rule(s): ${invokedRules}`);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
this.logger.warn(`Check ${check.name} on Submission (ID ${itemId}) failed with error: ${e.message}`, e);
|
||||
this.logger.warn(`[${itemIdentifier}] [CHK ${check.name}] Failed with error: ${e.message}`, e);
|
||||
}
|
||||
|
||||
if (triggered) {
|
||||
this.logger.debug(`[${itemIdentifier}] [CHK ${check.name}] Running actions`);
|
||||
// TODO give actions a name
|
||||
await check.runActions(item, currentResults);
|
||||
this.logger.debug(`Ran actions for Check ${check.name}`);
|
||||
this.logger.info(`[${itemIdentifier}] [CHK ${check.name}] Ran actions`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
heartbeat() {
|
||||
const apiRemaining = this.client.ratelimitRemaining;
|
||||
if(this.heartbeatInterval !== undefined && dayjs().diff(this.lastHeartbeat) >= this.heartbeatInterval) {
|
||||
this.logger.info(`HEARTBEAT -- Reddit API Rate Limit remaining: ${apiRemaining}`);
|
||||
this.lastHeartbeat = dayjs();
|
||||
}
|
||||
if(apiRemaining < this.apiLimitWarning) {
|
||||
this.logger.warn(`Reddit API rate limit remaining: ${apiRemaining} (Warning at ${this.apiLimitWarning})`);
|
||||
}
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
if (this.submissionChecks.length > 0) {
|
||||
const {
|
||||
@@ -114,6 +138,7 @@ export class Manager {
|
||||
}
|
||||
await this.runChecks('Submission', item)
|
||||
});
|
||||
this.streamSub.on('listing', (_) => this.heartbeat());
|
||||
}
|
||||
|
||||
if (this.commentChecks.length > 0) {
|
||||
@@ -135,6 +160,7 @@ export class Manager {
|
||||
}
|
||||
await this.runChecks('Comment', item)
|
||||
});
|
||||
this.streamComments.on('listing', (_) => this.heartbeat());
|
||||
}
|
||||
|
||||
if (this.streamSub !== undefined) {
|
||||
|
||||
27
src/Utils/CommandConfig.ts
Normal file
27
src/Utils/CommandConfig.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {Option} from "commander";
|
||||
|
||||
export const getOptions = () => {
|
||||
const options = [];
|
||||
|
||||
const clientIdOption = new Option('-c, --clientId <id>', 'Client ID for your Reddit application').default(process.env.CLIENT_ID, 'process.env.CLIENT_ID');
|
||||
clientIdOption.required = true;
|
||||
options.push(clientIdOption);
|
||||
|
||||
const clientSecretOption = new Option('-e, --clientSecret <secret>', 'Client Secret for your Reddit application').default(process.env.CLIENT_SECRET, 'process.env.CLIENT_SECRET');
|
||||
clientSecretOption.required = true;
|
||||
options.push(clientSecretOption);
|
||||
const accessTokenOption = new Option('-a, --accessToken <token>', 'Access token retrieved from authenticating an account with your Reddit Application').default(process.env.ACCESS_TOKEN, 'process.env.ACCESS_TOKEN');
|
||||
accessTokenOption.required = true;
|
||||
options.push(accessTokenOption);
|
||||
const refreshTokenOption = new Option('-r, --refreshToken <token>', 'Refresh token retrieved from authenticating an account with your Reddit Application').default(process.env.REFRESH_TOKEN, 'process.env.REFRESH_TOKEN');
|
||||
refreshTokenOption.required = true;
|
||||
options.push(refreshTokenOption);
|
||||
|
||||
options.push(new Option('-s, --subreddits <list...>', 'List of subreddits to run on. Bot will run on all subs it has access to if not defined').default(process.env.SUBREDDITS || [], 'process.env.SUBREDDITS (comma-seperated)'));
|
||||
options.push(new Option('-d, --logDir <dir>', 'Absolute path to directory to store rotated logs in').default(process.env.LOG_DIR || `${process.cwd()}/logs`, 'process.env.LOG_DIR'));
|
||||
options.push(new Option('-l, --logLevel <level>', 'Log level').default(process.env.LOG_LEVEL, 'process.env.LOG_LEVEL'));
|
||||
options.push(new Option('-w, --wikiConfig <path>', 'Relative url to contextbot wiki page (from https://reddit.com/r/subreddit/wiki/<path>').default(process.env.WIKI_CONFIG || 'botconfig/contextbot', 'process.env.WIK_CONFIG || \'botconfig/contextbot\''));
|
||||
options.push(new Option('-n, --snooDebug', 'Set Snoowrap to debug').default(process.env.SNOO_DEBUG || false, 'process.env.SNOO_DEBUG || false'));
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -13,12 +13,15 @@ export interface AuthorTypedActivitiesOptions extends AuthorActivitiesOptions {
|
||||
|
||||
export interface AuthorActivitiesOptions {
|
||||
window: ActivityWindowType | Duration
|
||||
chunkSize?: number
|
||||
}
|
||||
|
||||
export async function getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
|
||||
|
||||
const {chunkSize: cs = 100} = options;
|
||||
|
||||
let window: number | Dayjs,
|
||||
chunkSize = 30;
|
||||
chunkSize = Math.min(cs, 100);
|
||||
if (typeof options.window !== 'number') {
|
||||
const endTime = dayjs();
|
||||
let d;
|
||||
@@ -54,25 +57,36 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
|
||||
break;
|
||||
}
|
||||
let hitEnd = false;
|
||||
let offset = chunkSize;
|
||||
while (!hitEnd) {
|
||||
items = items.concat(listing);
|
||||
|
||||
if (typeof window === 'number') {
|
||||
hitEnd = items.length >= window
|
||||
hitEnd = listing.length >= window;
|
||||
} else {
|
||||
const lastItem = listing[listing.length - 1];
|
||||
const lastUtc = await lastItem.created_utc
|
||||
lastItemDate = dayjs(lastUtc);
|
||||
if (lastItemDate.isBefore(window)) {
|
||||
const listSlice = listing.slice(offset - chunkSize);
|
||||
|
||||
const truncatedItems = listSlice.filter((x) => {
|
||||
const utc = x.created_utc * 1000;
|
||||
const itemDate = dayjs(utc);
|
||||
// @ts-ignore
|
||||
return window.isBefore(itemDate);
|
||||
});
|
||||
if(truncatedItems.length !== listSlice.length) {
|
||||
hitEnd = true;
|
||||
}
|
||||
items = items.concat(truncatedItems);
|
||||
}
|
||||
if (!hitEnd) {
|
||||
hitEnd = listing.isFinished;
|
||||
}
|
||||
if (!hitEnd) {
|
||||
listing.fetchMore({amount: chunkSize});
|
||||
offset += chunkSize;
|
||||
listing = await listing.fetchMore({amount: chunkSize});
|
||||
} else if(typeof window === 'number') {
|
||||
items = listing.slice(0, window + 1);
|
||||
}
|
||||
}
|
||||
// TODO truncate items to window size when duration
|
||||
return Promise.resolve(items);
|
||||
}
|
||||
|
||||
@@ -186,6 +200,7 @@ export interface ItemContent {
|
||||
submissionTitle: string,
|
||||
content: string,
|
||||
author: string,
|
||||
permalink: string,
|
||||
}
|
||||
|
||||
export const itemContentPeek = async (item: (Comment | Submission), peekLength = 200): Promise<[string, ItemContent]> => {
|
||||
@@ -193,27 +208,54 @@ export const itemContentPeek = async (item: (Comment | Submission), peekLength =
|
||||
let content = '';
|
||||
let submissionTitle = '';
|
||||
let peek = '';
|
||||
// @ts-ignore
|
||||
const client = item._r as Snoowrap;
|
||||
const author = item.author.name;
|
||||
if (item instanceof Submission) {
|
||||
submissionTitle = item.title;
|
||||
peek = `${truncatePeek(item.title)} by ${author}`;
|
||||
peek = `${truncatePeek(item.title)} by ${author} https://reddit.com${item.permalink}`;
|
||||
|
||||
} else if (item instanceof Comment) {
|
||||
content = truncatePeek(item.body)
|
||||
try {
|
||||
// @ts-ignore
|
||||
const client = item._r as Snoowrap;
|
||||
const client = item._r as Snoowrap; // protected? idgaf
|
||||
// @ts-ignore
|
||||
const commentSub = await client.getSubmission(item.link_id);
|
||||
const [p, {submissionTitle: subTitle}] = await itemContentPeek(commentSub);
|
||||
submissionTitle = subTitle;
|
||||
peek = `${truncatePeek(content)} in ${p}`;
|
||||
peek = `${truncatePeek(content)} in ${subTitle} by ${author} https://reddit.com${item.permalink}`;
|
||||
} catch (err) {
|
||||
// possible comment is not on a submission, just swallow
|
||||
}
|
||||
}
|
||||
|
||||
return [peek, {submissionTitle, content, author}];
|
||||
return [peek, {submissionTitle, content, author, permalink: item.permalink}];
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
export const getSubmissionFromComment = async (item: Comment): Promise<Submission> => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const client = item._r as Snoowrap; // protected? idgaf
|
||||
// @ts-ignore
|
||||
return client.getSubmission(item.link_id);
|
||||
} catch (err) {
|
||||
// possible comment is not on a submission, just swallow
|
||||
}
|
||||
}
|
||||
|
||||
export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain = false) => {
|
||||
let domain = sub.domain;
|
||||
if (!useParentMediaDomain && sub.secure_media?.oembed !== undefined) {
|
||||
const {
|
||||
author_url,
|
||||
author_name,
|
||||
} = sub.secure_media?.oembed;
|
||||
if (author_name !== undefined) {
|
||||
domain = author_name;
|
||||
} else if (author_url !== undefined) {
|
||||
domain = author_url;
|
||||
}
|
||||
}
|
||||
|
||||
return domain;
|
||||
}
|
||||
|
||||
205
src/index.ts
205
src/index.ts
@@ -1,157 +1,106 @@
|
||||
import snoowrap from "snoowrap";
|
||||
import minimist from 'minimist';
|
||||
import winston from 'winston';
|
||||
import 'winston-daily-rotate-file';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import dduration from 'dayjs/plugin/duration.js';
|
||||
import {labelledFormat} from "./util";
|
||||
import EventEmitter from "events";
|
||||
import relTime from 'dayjs/plugin/relativeTime.js';
|
||||
import {Manager} from "./Subreddit/Manager";
|
||||
import pEvent from "p-event";
|
||||
import {Command} from 'commander';
|
||||
import {getOptions} from "./Utils/CommandConfig";
|
||||
import {App} from "./App";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {COMMENT_URL_ID, parseLinkIdentifier, SUBMISSION_URL_ID} from "./util";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(dduration);
|
||||
dayjs.extend(relTime);
|
||||
|
||||
const {transports} = winston;
|
||||
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
|
||||
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
|
||||
|
||||
const argv = minimist(process.argv.slice(2));
|
||||
const {
|
||||
_: subredditsArgs = [],
|
||||
clientId = process.env.CLIENT_ID,
|
||||
clientSecret = process.env.CLIENT_SECRET,
|
||||
accessToken = process.env.ACCESS_TOKEN,
|
||||
refreshToken = process.env.REFRESH_TOKEN,
|
||||
logDir = process.env.LOG_DIR,
|
||||
logLevel = process.env.LOG_LEVEL,
|
||||
wikiConfig = process.env.WIKI_CONFIG,
|
||||
} = argv;
|
||||
|
||||
const logPath = logDir ?? `${process.cwd()}/logs`;
|
||||
|
||||
// @ts-ignore
|
||||
const rotateTransport = new winston.transports.DailyRotateFile({
|
||||
dirname: logPath,
|
||||
createSymlink: true,
|
||||
symlinkName: 'contextBot-current.log',
|
||||
filename: 'contextBot-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
});
|
||||
|
||||
const consoleTransport = new transports.Console();
|
||||
|
||||
const myTransports = [
|
||||
consoleTransport,
|
||||
];
|
||||
|
||||
if (typeof logPath === 'string') {
|
||||
// @ts-ignore
|
||||
myTransports.push(rotateTransport);
|
||||
const program = new Command();
|
||||
for (const o of getOptions()) {
|
||||
program.addOption(o);
|
||||
}
|
||||
|
||||
const loggerOptions = {
|
||||
level: logLevel || 'info',
|
||||
format: labelledFormat(),
|
||||
transports: myTransports,
|
||||
};
|
||||
|
||||
winston.loggers.add('default', loggerOptions);
|
||||
|
||||
const logger = winston.loggers.get('default');
|
||||
|
||||
const version = process.env.VERSION || 'dev';
|
||||
|
||||
const wikiLocation = wikiConfig || 'botconfig/contextbot';
|
||||
|
||||
let subredditsArg = subredditsArgs;
|
||||
if (subredditsArg.length === 0) {
|
||||
// try to get from comma delim env variable
|
||||
const subenv = process.env.SUBREDDITS;
|
||||
if (typeof subenv === 'string') {
|
||||
subredditsArg = subenv.split(',');
|
||||
}
|
||||
}
|
||||
(async function () {
|
||||
const creds = {
|
||||
userAgent: `web:contextBot:${version}`,
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
};
|
||||
try {
|
||||
const client = new snoowrap(creds);
|
||||
client.config({warnings: true, retryErrorCodes: [500], maxRetryAttempts: 2, debug: logLevel === 'debug'});
|
||||
|
||||
//const me = await client.getMe().name;
|
||||
program
|
||||
.command('run')
|
||||
.description('Runs bot normally')
|
||||
.action(async (run, command) => {
|
||||
const app = new App(program.opts());
|
||||
await app.buildManagers();
|
||||
await app.runManagers();
|
||||
});
|
||||
|
||||
// determine which subreddits this account has appropriate access to
|
||||
let availSubs = [];
|
||||
for (const sub of await client.getModeratedSubreddits()) {
|
||||
// TODO don't know a way to check permissions yet
|
||||
availSubs.push(sub);
|
||||
// if(sub.user_is_moderator) {
|
||||
// const modUser = sub.getModerators().find(x => x.name === myName);
|
||||
// const canMod = modUser.features
|
||||
// }
|
||||
}
|
||||
program
|
||||
.command('check <activityIdentifier> [type]')
|
||||
.description('Run check(s) on a specific activity', {
|
||||
activityIdentifier: 'Either a permalink URL or the ID of the Comment or Submission',
|
||||
type: `If activityIdentifier is not a permalink URL then the type of activity ('comment' or 'submission'). May also specify 'submission' type when using a permalink to a comment to get the Submission`,
|
||||
})
|
||||
.option('-h, --checks <checkNames...>', 'An optional list of Checks, by name, that should be run. If none are specified all Checks for the Subreddit the Activity is in will be run')
|
||||
.action(async (activityIdentifier, type, commandOptions = {}) => {
|
||||
const {checks = []} = commandOptions;
|
||||
const app = new App(program.opts());
|
||||
|
||||
let subsToRun = [];
|
||||
// if user specified subs to run on check they are all subs client can mod
|
||||
if (subredditsArgs.length > 0) {
|
||||
for (const sub of subredditsArg) {
|
||||
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.trim().toLowerCase())
|
||||
if (asub === undefined) {
|
||||
logger.error(`Will not run on ${sub} because is not modded by, or does not have appropriate permissions to mod with, for this client.`);
|
||||
} else {
|
||||
subsToRun.push(asub);
|
||||
let a;
|
||||
const commentId = commentReg(activityIdentifier);
|
||||
if (commentId !== undefined) {
|
||||
if (type !== 'submission') {
|
||||
// @ts-ignore
|
||||
a = await app.client.getComment(commentId);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
a = await app.client.getSubmission(submissionReg(activityIdentifier) as string);
|
||||
}
|
||||
}
|
||||
if (a === undefined) {
|
||||
const submissionId = submissionReg(activityIdentifier);
|
||||
if (submissionId !== undefined) {
|
||||
if (type === 'comment') {
|
||||
throw new Error(`Detected URL was for a submission but type was 'comment', cannot get activity`);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
a = await app.client.getSubmission(submissionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// otherwise assume all moddable subs from client should be run on
|
||||
subsToRun = availSubs;
|
||||
}
|
||||
|
||||
let subSchedule: Manager[] = [];
|
||||
if (a === undefined) {
|
||||
// if we get this far then probably not a URL
|
||||
if (type === undefined) {
|
||||
throw new Error(`activityIdentifier was not a valid Reddit URL and type was not specified`);
|
||||
}
|
||||
if (type === 'comment') {
|
||||
// @ts-ignore
|
||||
a = await app.client.getComment(activityIdentifier);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
a = await app.client.getSubmission(activityIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
// get configs for subs we want to run on and build/validate them
|
||||
for (const sub of subsToRun) {
|
||||
let content = undefined;
|
||||
let json = undefined;
|
||||
try {
|
||||
const wiki = sub.getWikiPage(wikiLocation);
|
||||
content = await wiki.content_md;
|
||||
} catch (err) {
|
||||
logger.error(`Could not read wiki configuration for ${sub.display_name}. Please ensure the page 'contextbot' exists and is readable -- error: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
// @ts-ignore
|
||||
const activity = await a.fetch();
|
||||
const sub = await activity.subreddit.display_name;
|
||||
await app.buildManagers([sub]);
|
||||
if (app.subManagers.length > 0) {
|
||||
const manager = app.subManagers.find(x => x.subreddit.display_name === sub) as Manager;
|
||||
await manager.runChecks(type === 'comment' ? 'Comment' : 'Submission', activity, checks);
|
||||
}
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
logger.error(`Wiki page contents for ${sub.display_name} was not valid -- error: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
subSchedule.push(new Manager(sub, client, logger, json));
|
||||
} catch (err) {
|
||||
logger.error(`Config for ${sub.display_name} was not valid, will not run for this subreddit`);
|
||||
}
|
||||
}
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
await program.parseAsync();
|
||||
|
||||
for (const manager of subSchedule) {
|
||||
manager.handle();
|
||||
}
|
||||
|
||||
// never hits so we can run indefinitely
|
||||
await pEvent(emitter, 'end');
|
||||
} catch (err) {
|
||||
if(err.name === 'StatusCodeError' && err.response !== undefined) {
|
||||
const logger = winston.loggers.get('default');
|
||||
if (err.name === 'StatusCodeError' && err.response !== undefined) {
|
||||
const authHeader = err.response.headers['www-authenticate'];
|
||||
if(authHeader !== undefined && authHeader.includes('insufficient_scope')) {
|
||||
if (authHeader !== undefined && authHeader.includes('insufficient_scope')) {
|
||||
logger.error('Reddit responded with a 403 insufficient_scope, did you choose the correct scopes?');
|
||||
}
|
||||
}
|
||||
|
||||
47
src/util.ts
47
src/util.ts
@@ -30,7 +30,19 @@ export const loggerMetaShuffle = (logger: Logger, newLeaf: (string | undefined |
|
||||
|
||||
let longestLabel = 3;
|
||||
// @ts-ignore
|
||||
export const defaultFormat = printf(({level, message, label = 'App', labels = [], leaf, itemId, timestamp, [SPLAT]: splatObj, stack, ...rest}) => {
|
||||
export const defaultFormat = printf(({
|
||||
level,
|
||||
message,
|
||||
label = 'App',
|
||||
labels = [],
|
||||
leaf,
|
||||
itemId,
|
||||
timestamp,
|
||||
// @ts-ignore
|
||||
[SPLAT]: splatObj,
|
||||
stack,
|
||||
...rest
|
||||
}) => {
|
||||
let stringifyValue = splatObj !== undefined ? jsonStringify(splatObj) : '';
|
||||
if (label.length > longestLabel) {
|
||||
longestLabel = label.length;
|
||||
@@ -48,9 +60,9 @@ export const defaultFormat = printf(({level, message, label = 'App', labels = []
|
||||
}
|
||||
|
||||
let labelContent = `[${label.padEnd(longestLabel)}]`;
|
||||
if(labels.length > 0 || leaf !== null) {
|
||||
if (labels.length > 0 || (leaf !== null && leaf !== undefined)) {
|
||||
let nodes = labels;
|
||||
if(leaf !== null) {
|
||||
if (leaf !== null) {
|
||||
nodes.push(leaf);
|
||||
}
|
||||
//labelContent = `${labels.slice(0, labels.length).map((x: string) => `[${x}]`).join(' ')}`
|
||||
@@ -114,6 +126,10 @@ export const groupBy = <T>(keys: (keyof T)[], opts: groupByOptions = {}) => (arr
|
||||
}, {} as Record<string, T[]>)
|
||||
};
|
||||
|
||||
// match /mealtimesvideos/ /comments/ etc... (?:\/.*\/)
|
||||
// matches https://old.reddit.com/r (?:^.+?)(?:reddit.com\/r)
|
||||
// (?:^.+?)(?:reddit.com\/r\/.+\/.\/)
|
||||
// (?:.*\/)([\d\w]+?)(?:\/*)
|
||||
|
||||
/**
|
||||
* @see https://stackoverflow.com/a/61033353/1469797
|
||||
@@ -129,12 +145,29 @@ export const parseUsableLinkIdentifier = (regexes: RegExp[] = [REGEX_YOUTUBE]) =
|
||||
if (matches.length > 0) {
|
||||
// use first capture group
|
||||
// TODO make this configurable at some point?
|
||||
return matches[0][matches[0].length - 1];
|
||||
const captureGroup = matches[0][matches[0].length - 1];
|
||||
if(captureGroup !== '') {
|
||||
return captureGroup;
|
||||
}
|
||||
}
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
export const parseLinkIdentifier = (regexes: RegExp[]) => {
|
||||
const u = parseUsableLinkIdentifier(regexes);
|
||||
return (val: string): (string | undefined) => {
|
||||
const id = u(val);
|
||||
if (id === val) {
|
||||
return undefined;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
export const SUBMISSION_URL_ID: RegExp = /(?:^.+?)(?:reddit.com\/r)(?:\/[\w\d]+){2}(?:\/)([\w\d]*)/g;
|
||||
export const COMMENT_URL_ID: RegExp = /(?:^.+?)(?:reddit.com\/r)(?:\/[\w\d]+){4}(?:\/)([\w\d]*)/g;
|
||||
|
||||
export function sleep(ms: number) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -173,8 +206,12 @@ export const determineNewResults = (existing: RuleResult[], val: RuleResult | Ru
|
||||
return newResults;
|
||||
}
|
||||
|
||||
export const mergeArr = (objValue: [], srcValue: []): (any[]|undefined) => {
|
||||
export const mergeArr = (objValue: [], srcValue: []): (any[] | undefined) => {
|
||||
if (Array.isArray(objValue)) {
|
||||
return objValue.concat(srcValue);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user