Compare commits

..

32 Commits
0.1.1 ... 0.2.0

Author SHA1 Message Date
FoxxMD
318a1d3326 Set heroku url to default branch 2021-06-08 16:16:58 -04:00
FoxxMD
08db50426b Show check details and summary by default (info level) 2021-06-08 16:15:36 -04:00
FoxxMD
77f7a0167c Wiki value typo 2021-06-08 16:10:44 -04:00
FoxxMD
23a9f9d652 Remove potentially problematic heroku env 2021-06-08 16:09:00 -04:00
FoxxMD
72ed72ce4a Add heroku quick deploy button 2021-06-08 16:07:53 -04:00
FoxxMD
8cea19c7f2 Remove default env vars 2021-06-08 16:01:18 -04:00
FoxxMD
8eeaac2d53 Update heroku app file 2021-06-08 15:41:57 -04:00
FoxxMD
3cf838ba9f Create heroku app file 2021-06-08 15:39:14 -04:00
FoxxMD
16f3c2268b Create heroku file 2021-06-08 15:31:34 -04:00
FoxxMD
3be20b910d Fix missing return on activity filter 2021-06-08 14:03:49 -04:00
FoxxMD
78aed4321a Add support for reddit permalink when running check command 2021-06-08 13:55:03 -04:00
FoxxMD
0fe2fa8934 Add submission from comments convenience method 2021-06-08 13:46:45 -04:00
FoxxMD
37ba1dc1bf Fix default value when reference submission has no repeats 2021-06-08 13:46:08 -04:00
FoxxMD
5905c910b0 Implement name references for actions and rules
Action/Rule objects they can now be referenced by name from anywhere in the configuration
2021-06-08 12:40:00 -04:00
FoxxMD
d239d3c6cc Update dockerfile to use default run command 2021-06-08 00:52:23 -04:00
FoxxMD
16d0eebac6 Some small fixes for attribution 2021-06-08 00:35:34 -04:00
FoxxMD
1a393944c0 Refactor AttributionRule to be more robust and handle multiple window/thresholds
It's more useful to be abel to check thresholds for multiple windows to get a more holistic idea of attribution percents
2021-06-08 00:32:25 -04:00
FoxxMD
9f270010b7 Add trace to winston log levels so it can be used with snoowrap 2021-06-08 00:31:31 -04:00
FoxxMD
2548cff367 Friendly print schema validation errors 2021-06-08 00:30:57 -04:00
FoxxMD
c7acda46a0 Implement AttributionRule 2021-06-07 17:45:08 -04:00
FoxxMD
530675179b Refactor activity window
* Truncate items to window length when too many retrieved
* Correctly compare dates
2021-06-07 17:44:20 -04:00
FoxxMD
7960423678 Fix missing bold format character 2021-06-07 14:09:11 -04:00
FoxxMD
4ddb0f0963 Update readme with new cli syntax 2021-06-07 13:58:16 -04:00
FoxxMD
8a54ce15cd Refactor RepeatSubmission rule into RepeatActivity to allow more flexibility in use
* Refactor item repeat logic completely to simplify allow scenarios
* Can check for repeat comments now
* Add more context data and markdown content to display all repeats
2021-06-07 13:39:50 -04:00
FoxxMD
01161c3493 Implement specific activity checking through cli
* Refactor application input to use commando for extensibility
* Add all args/env as options for easy readout on command line
* Add 'check' command to allow running checks against a specific activity
* BC: must specify 'run' to run regular manager/unattended operation
2021-06-07 13:38:37 -04:00
FoxxMD
9970156a3d Fix leaf detection when undefined 2021-06-07 13:36:11 -04:00
FoxxMD
b437156d99 Clean up logging 2021-06-04 16:20:25 -04:00
FoxxMD
de3a279dc3 More verbose debug logging of checks added to subreddit manager on load 2021-06-04 15:07:30 -04:00
FoxxMD
86a6a75119 Add missing domain prefix to permalink 2021-06-04 15:03:32 -04:00
FoxxMD
9634b59b3a Add some temporal convenience logging
* heartbeat logging with configurable interval
* configurable api limit warning
2021-06-04 14:58:13 -04:00
FoxxMD
37f7c99155 Add snoodebug arg/env to control snoowrap debug output independently 2021-06-04 14:53:59 -04:00
FoxxMD
a99ab9a64a Add permalink to peek content to make logs more convenient 2021-06-04 14:53:29 -04:00
33 changed files with 2309 additions and 549 deletions

View File

@@ -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" ]

View File

@@ -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)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](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
View 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
View File

@@ -0,0 +1,3 @@
build:
docker:
worker: Dockerfile

84
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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.');
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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 {
}

View File

@@ -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
View 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');
}
}

View File

@@ -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>
}

View File

@@ -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
View 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>;

View File

@@ -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;
}

View File

@@ -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[]
}

View File

@@ -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.');
}

View File

@@ -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;
}

View 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'
}

View 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;

View File

@@ -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;

View File

@@ -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'
}

View File

@@ -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"

View File

@@ -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"
}
}

View File

@@ -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"
}
]
},

View File

@@ -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) {

View 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;
}

View File

@@ -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;
}

View File

@@ -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?');
}
}

View File

@@ -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);
}
}