Compare commits

...

51 Commits
0.3.0 ... 0.4.2

Author SHA1 Message Date
FoxxMD
8e2fee6d50 Fix heartbeat interval 2021-07-06 16:52:45 -04:00
FoxxMD
ed8be6dda2 Refactor app/manager building for in-situ updates
* Separate manager instantiation from configuration flow so config can be reloaded
* Move wiki page parsing into manager for better encapsulation
* Check for wiki revision date on heartbeat and on checks if older than one minute
* Catch config parsing issues and retry on next heartbeat
2021-07-06 16:28:18 -04:00
FoxxMD
00e38b5560 Use correct media property for anchor parsing 2021-07-06 12:59:46 -04:00
FoxxMD
9cac11f436 Implement author parsing for audio/podcast related media
Parse spotify and anchor.fm media sources
2021-07-06 11:33:00 -04:00
FoxxMD
f591c3a05a Implement more powerful content parsing options
* Can get wiki pages from other subreddits
* Can fetch from an external url
2021-07-06 10:27:30 -04:00
FoxxMD
39fad91c7f Fix missing author criteria 2021-07-05 17:20:35 -04:00
FoxxMD
529b8fc03e Further improvements for subreddit name parsing
* Allow whitespace on either side of regex value to parse since its automatically trimmed by getting capture group
* Implement sub name parsing everywhere subreddits can be specified and update documentation to remove prefix restrictions
2021-07-05 16:06:15 -04:00
FoxxMD
54eef5620d Update interfaces and documentation for new filters and item states 2021-07-05 15:39:08 -04:00
FoxxMD
99537fbebb Fix missing filtering behavior on repeat and add remove check
* Add missing include/exclude behavior for counting repeat submissions
* Add parameter to enable user to specify if removed activities should be included
2021-07-05 15:38:49 -04:00
FoxxMD
4c3f9ee082 Fix remove check on remove action 2021-07-05 15:37:37 -04:00
FoxxMD
5b028b6a45 Fix some item state checks and implement subreddit filtering on window
* Fix how removed check is performed since there are different behaviors for submission/comment
* Add filtered and deleted states for item check
* Add subreddit filters (include/exclude) on window criteria
2021-07-05 15:37:19 -04:00
FoxxMD
859bcf9213 Implement subreddit name parser to allow more lax input
Use regex to extract subreddit name regardless of prefix
2021-07-05 15:34:59 -04:00
FoxxMD
e790f7c260 Fix issue when activities retrieved for attribution rule 2021-06-25 15:20:31 -04:00
FoxxMD
20358294ce Fix domain ident aliases when not aggregating parent domain 2021-06-25 10:29:19 -04:00
FoxxMD
e0f18dc0a2 Add typescript dep 2021-06-25 10:28:39 -04:00
FoxxMD
9a788a8323 Add file logging for uncaught rejection/exceptions 2021-06-23 16:05:58 -04:00
FoxxMD
bed9a9682a Add item is key in log on found 2021-06-23 15:17:45 -04:00
FoxxMD
d39ce13209 Big improvements for Attribution Rule
* Move most qualifier properties into criteria
* Allow filtering on specific domains and consolidate "use sub as reference" into a special string to use here
* Above also enables using rule on Comment
* Enable more submission type filtering options and allow multiple options (link, media, self -- or a combination of any)
* Provide count and percent ranges in results
* Provide domains and friendly tiles in results
2021-06-23 15:09:56 -04:00
FoxxMD
4bd25e53b0 Better formatting for logging errors
Don't erase the original message if there is one
2021-06-23 14:56:20 -04:00
FoxxMD
ac87d5acfa Check if Activity is deleted before running checks 2021-06-23 14:55:45 -04:00
FoxxMD
0f541f1961 Try to fix attribution undefined issue on criteria results array having undefined index 2021-06-22 16:26:30 -04:00
FoxxMD
db2be949b4 Refactor polling to enable polling from multiple sources
* New polling sources: unmoderated and modqueue
* Can now poll from many sources
2021-06-22 14:47:20 -04:00
FoxxMD
8c6b18cf4d Add optional subreddit nickname to help make logs more readable 2021-06-22 13:00:53 -04:00
FoxxMD
add4204304 Make check labels more concise
* Shorten prefix to 'CHK'
* Truncate check names to 25 characters
2021-06-22 13:00:35 -04:00
FoxxMD
927d4ef07e Refactor recent activity to provide useful information on fail 2021-06-22 12:42:43 -04:00
FoxxMD
b8c12009ee Refactor history rule to provide useful logging information on fail 2021-06-22 11:59:43 -04:00
FoxxMD
7f9b4ce6a0 Refactor repeat rule to provide useful logging information on fail 2021-06-22 11:29:07 -04:00
FoxxMD
ad8a668a08 Make pass/fail symbols constants 2021-06-22 11:28:37 -04:00
FoxxMD
84c5e97c92 Refactor Attribution so fail condition also logs useful information 2021-06-22 10:52:29 -04:00
FoxxMD
03b2cb36ab Change pass/fail symbols so they aren't emojis
Makes logging more uniform
2021-06-22 10:52:01 -04:00
FoxxMD
93bdb89115 Shorten action logging label 2021-06-22 10:51:29 -04:00
FoxxMD
702e2ccccf Shorten Rule labels 2021-06-22 10:47:51 -04:00
FoxxMD
631d67928d Shorten kind for some rules to make logging more readable 2021-06-22 10:47:13 -04:00
FoxxMD
eea04344c0 Check for error timeout code 2021-06-21 22:53:50 -04:00
FoxxMD
7f29ade87b Refactor results to make check complexity easier to visualize in log
* Differentiate rule results from rule set results
* Implement function to parse all results for check and print and/or PLUS triggered state for all rule/sets in check
2021-06-21 17:10:24 -04:00
FoxxMD
cced86381b Fix minimum items for subthreshold 2021-06-21 13:57:31 -04:00
FoxxMD
01c575f2b2 Rename history criteria operator name to be consistent 2021-06-21 13:34:27 -04:00
FoxxMD
f1d04d4718 Fix regex named group 2021-06-21 13:33:49 -04:00
FoxxMD
6ca65079b3 Add author and item checks to Actions
So that Actions can be skipped based on item/author criteria
2021-06-21 13:00:45 -04:00
FoxxMD
73236e44ad Add karma comparisons and verified check for author criteria 2021-06-21 12:02:33 -04:00
FoxxMD
4bef85e1e4 Refactor most thresholds to use comparison operators (like automod) 2021-06-21 11:40:04 -04:00
FoxxMD
532f6aa3d8 Fix recent activity default thresholds 2021-06-20 14:20:39 -04:00
FoxxMD
e1e5b26264 One more duration fix 2021-06-20 14:15:06 -04:00
FoxxMD
46a583e20a Fix regex constant usage for duration 2021-06-20 14:01:00 -04:00
FoxxMD
24064dfe03 Refactor how activity window is parsed to use dayjs shorthand string
* Parse dayjs shorthand IE "90 days" alongside the rest of the activity types
* Update schema with clearer information on how activity window can be formed
2021-06-18 17:36:43 -04:00
FoxxMD
ad91901cc2 Refactor history criteria to use string regex parsing (like automod) 2021-06-18 16:33:13 -04:00
FoxxMD
58c51e56b1 Test Author account age using string comparison (like automod) 2021-06-18 13:52:52 -04:00
FoxxMD
9850ccb8f3 Fix dryrun usage for comment action 2021-06-17 15:04:11 -04:00
FoxxMD
79b82dab0f Refactor footer to be configurable at subreddit and Action level 2021-06-17 14:43:22 -04:00
FoxxMD
9c059beb85 Add usernotes to content template render data and contextualize bot footer 2021-06-17 13:13:46 -04:00
FoxxMD
88be7d8836 Missed ban case in action factory 2021-06-16 22:42:46 -04:00
37 changed files with 3880 additions and 1714 deletions

View File

@@ -113,7 +113,8 @@ All Actions with `content` have access to this data:
author: 'string', // name of the item author (reddit user)
permalink: 'string', // a url to the item
url: 'string', // if the item is a Submission then its URL (external for link type submission, reddit link for self-posts)
title: 'string', // if the item is a Submission, then the title of the Submission
title: 'string', // if the item is a Submission, then the title of the Submission,
botLink: 'string' // a link to the bot's FAQ
},
rules: {
// contains all rules that were run and are accessible using the name, lowercased, with all spaces/dashes/underscores removed

119
package-lock.json generated
View File

@@ -18,12 +18,14 @@
"json5": "^2.2.0",
"memory-cache": "^0.2.0",
"mustache": "^4.2.0",
"node-fetch": "^2.6.1",
"object-hash": "^2.2.0",
"p-event": "^4.2.0",
"pako": "^0.2.6",
"safe-stable-stringify": "^1.1.1",
"snoostorm": "^1.5.2",
"snoowrap": "^1.23.0",
"typescript": "^4.3.4",
"winston": "FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
"winston-daily-rotate-file": "^4.5.5",
"zlib": "^1.0.5"
@@ -36,6 +38,7 @@
"@types/minimist": "^1.2.1",
"@types/mustache": "^4.1.1",
"@types/node": "^15.6.1",
"@types/node-fetch": "^2.5.10",
"@types/object-hash": "^2.1.0",
"@types/pako": "^1.0.1",
"ts-auto-guard": "*",
@@ -118,6 +121,19 @@
"typescript": "~4.1.3"
}
},
"node_modules/@ts-morph/common/node_modules/typescript": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.6.tgz",
"integrity": "sha512-pxnwLxeb/Z5SP80JDRzVjh58KsM6jZHRAOtTpS7sXLS4ogXNKC9ANxHHZqLLeVHZN35jCtI4JdmLLbLiC1kBow==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/@tsconfig/node14": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.1.tgz",
@@ -190,6 +206,30 @@
"integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==",
"dev": true
},
"node_modules/@types/node-fetch": {
"version": "2.5.10",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.10.tgz",
"integrity": "sha512-IpkX0AasN44hgEad0gEF/V6EgR5n69VEqPEgnmoM8GsIGro3PowbWs4tR6IhxUTyPLpOn+fiGG6nrQhcmoCuIQ==",
"dev": true,
"dependencies": {
"@types/node": "*",
"form-data": "^3.0.0"
}
},
"node_modules/@types/node-fetch/node_modules/form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/@types/object-hash": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.1.0.tgz",
@@ -1255,6 +1295,14 @@
"mustache": "bin/mustache"
}
},
"node_modules/node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw==",
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
@@ -1827,19 +1875,6 @@
"node": ">=10.0.0"
}
},
"node_modules/ts-json-schema-generator/node_modules/typescript": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.2.tgz",
"integrity": "sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
}
},
"node_modules/ts-morph": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-9.1.0.tgz",
@@ -1906,10 +1941,9 @@
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
},
"node_modules/typescript": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz",
"integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==",
"dev": true,
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz",
"integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew==",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2297,6 +2331,14 @@
"mkdirp": "^1.0.4",
"multimatch": "^5.0.0",
"typescript": "~4.1.3"
},
"dependencies": {
"typescript": {
"version": "4.1.6",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.6.tgz",
"integrity": "sha512-pxnwLxeb/Z5SP80JDRzVjh58KsM6jZHRAOtTpS7sXLS4ogXNKC9ANxHHZqLLeVHZN35jCtI4JdmLLbLiC1kBow==",
"dev": true
}
}
},
"@tsconfig/node14": {
@@ -2371,6 +2413,29 @@
"integrity": "sha512-zjQ69G564OCIWIOHSXyQEEDpdpGl+G348RAKY0XXy9Z5kU9Vzv1GMNnkar/ZJ8dzXB3COzD9Mo9NtRZ4xfgUww==",
"dev": true
},
"@types/node-fetch": {
"version": "2.5.10",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.5.10.tgz",
"integrity": "sha512-IpkX0AasN44hgEad0gEF/V6EgR5n69VEqPEgnmoM8GsIGro3PowbWs4tR6IhxUTyPLpOn+fiGG6nrQhcmoCuIQ==",
"dev": true,
"requires": {
"@types/node": "*",
"form-data": "^3.0.0"
},
"dependencies": {
"form-data": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz",
"integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
}
}
},
"@types/object-hash": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/object-hash/-/object-hash-2.1.0.tgz",
@@ -3241,6 +3306,11 @@
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="
},
"node-fetch": {
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.1.tgz",
"integrity": "sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw=="
},
"oauth-sign": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz",
@@ -3651,14 +3721,6 @@
"glob": "^7.1.7",
"json-stable-stringify": "^1.0.1",
"typescript": "~4.3.2"
},
"dependencies": {
"typescript": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.2.tgz",
"integrity": "sha512-zZ4hShnmnoVnAHpVHWpTcxdv7dWP60S2FsydQLV8V5PbS3FifjWFFRiHSWpDJahly88PRyV5teTSLoq4eG7mKw==",
"dev": true
}
}
},
"ts-morph": {
@@ -3712,10 +3774,9 @@
"integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q="
},
"typescript": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz",
"integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==",
"dev": true
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.3.4.tgz",
"integrity": "sha512-uauPG7XZn9F/mo+7MrsRjyvbxFpzemRjKEZXS4AK83oP2KKOJPvb+9cO/gmnv8arWZvhnjVOXz7B49m1l0e9Ew=="
},
"typescript-json-schema": {
"version": "0.50.1",

View File

@@ -34,12 +34,14 @@
"json5": "^2.2.0",
"memory-cache": "^0.2.0",
"mustache": "^4.2.0",
"node-fetch": "^2.6.1",
"object-hash": "^2.2.0",
"p-event": "^4.2.0",
"pako": "^0.2.6",
"safe-stable-stringify": "^1.1.1",
"snoostorm": "^1.5.2",
"snoowrap": "^1.23.0",
"typescript": "^4.3.4",
"winston": "FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
"winston-daily-rotate-file": "^4.5.5",
"zlib": "^1.0.5"
@@ -52,6 +54,7 @@
"@types/minimist": "^1.2.1",
"@types/mustache": "^4.1.1",
"@types/node": "^15.6.1",
"@types/node-fetch": "^2.5.10",
"@types/object-hash": "^2.1.0",
"@types/pako": "^1.0.1",
"ts-auto-guard": "*",

View File

@@ -7,6 +7,7 @@ import Action, {ActionJson} from "./index";
import {Logger} from "winston";
import {UserNoteAction, UserNoteActionJson} from "./UserNoteAction";
import ApproveAction, {ApproveActionConfig} from "./ApproveAction";
import BanAction, {BanActionJson} from "./BanAction";
export function actionFactory
(config: ActionJson, logger: Logger, subredditName: string): Action {
@@ -25,6 +26,8 @@ export function actionFactory
return new ApproveAction({...config as ApproveActionConfig, logger, subredditName});
case 'usernote':
return new UserNoteAction({...config as UserNoteActionJson, logger, subredditName});
case 'ban':
return new BanAction({...config as BanActionJson, logger, subredditName});
default:
throw new Error('rule "kind" was not recognized.');
}

View File

@@ -3,7 +3,7 @@ import Action from "./index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {RuleResult} from "../Rule";
import {renderContent} from "../Utils/SnoowrapUtils";
import {generateFooter} from "../util";
import {Footer} from "../Common/interfaces";
export class BanAction extends Action {
@@ -11,6 +11,7 @@ export class BanAction extends Action {
reason?: string;
duration?: number;
note?: string;
footer?: false | string;
constructor(options: BanActionOptions) {
super(options);
@@ -18,8 +19,10 @@ export class BanAction extends Action {
message,
reason,
duration,
note
note,
footer,
} = options;
this.footer = footer;
this.message = message;
this.reason = reason;
this.duration = duration;
@@ -32,21 +35,21 @@ export class BanAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
const content = this.message === undefined ? undefined : await this.resources.getContent(this.message, item.subreddit);
const renderedContent = content === undefined ? undefined : await renderContent(content, item, ruleResults);
const footer = await generateFooter(item);
const renderedBody = content === undefined ? undefined : await renderContent(content, item, ruleResults, this.resources.userNotes);
const renderedContent = renderedBody === undefined ? undefined : `${renderedBody}${await this.resources.generateFooter(item, this.footer)}`;
let banPieces = [];
banPieces.push(`Message: ${renderedContent === undefined ? 'None' : `${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`}`);
banPieces.push(`Reason: ${this.reason || 'None'}`);
banPieces.push(`Note: ${this.note || 'None'}`);
const durText = this.duration === undefined ? 'permanently' : `for ${this.duration} days`;
this.logger.verbose(`Banning ${item.author.name} ${durText}\r\n${banPieces.join('\r\n')}`);
this.logger.info(`Banning ${item.author.name} ${durText}${this.reason !== undefined ? ` (${this.reason})` : ''}`);
this.logger.verbose(`\r\n${banPieces.join('\r\n')}`);
if (!this.dryRun) {
// @ts-ignore
await item.subreddit.banUser({
name: item.author.id,
banMessage: renderedContent === undefined ? undefined : `${renderedContent}${footer}`,
banMessage: renderedContent === undefined ? undefined : renderedContent,
banReason: this.reason,
banNote: this.note,
duration: this.duration
@@ -55,7 +58,7 @@ export class BanAction extends Action {
}
}
export interface BanActionConfig extends ActionConfig {
export interface BanActionConfig extends ActionConfig, Footer {
/**
* The message that is sent in the ban notification. `message` is interpreted as reddit-flavored Markdown.
*
@@ -72,7 +75,7 @@ export interface BanActionConfig extends ActionConfig {
message?: string
/**
* Reason for ban.
* @maximum 100
* @maxLength 100
* @examples ["repeat spam"]
* */
reason?: string
@@ -85,7 +88,7 @@ export interface BanActionConfig extends ActionConfig {
duration?: number
/**
* A mod note for this ban
* @maximum 100
* @maxLength 100
* @examples ["Sock puppet for u/AnotherUser"]
* */
note?: string

View File

@@ -2,15 +2,15 @@ import Action, {ActionJson, ActionOptions} from "./index";
import {Comment} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {renderContent} from "../Utils/SnoowrapUtils";
import {RequiredRichContent, RichContent} from "../Common/interfaces";
import {Footer, RequiredRichContent, RichContent} from "../Common/interfaces";
import {RuleResult} from "../Rule";
import {generateFooter} from "../util";
export class CommentAction extends Action {
content: string;
lock: boolean = false;
sticky: boolean = false;
distinguish: boolean = false;
footer?: false | string;
constructor(options: CommentActionOptions) {
super(options);
@@ -19,7 +19,9 @@ export class CommentAction extends Action {
lock = false,
sticky = false,
distinguish = false,
footer,
} = options;
this.footer = footer;
this.content = content;
this.lock = lock;
this.sticky = sticky;
@@ -32,18 +34,22 @@ export class CommentAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults);
this.logger.verbose(`Contents:\r\n${renderedContent}`);
const body = await renderContent(content, item, ruleResults, this.resources.userNotes);
const footer = await this.resources.generateFooter(item, this.footer);
const renderedContent = `${body}${footer}`;
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
if(item.archived) {
this.logger.warn('Cannot comment because Item is archived');
return;
}
const footer = await generateFooter(item);
// @ts-ignore
const reply: Comment = await item.reply(`${renderedContent}${footer}`);
let reply: Comment;
if(!this.dryRun) {
// @ts-ignore
reply = await item.reply(renderedContent);
}
if (this.lock) {
if (!this.dryRun) {
// snoopwrap typing issue, thinks comments can't be locked
@@ -58,7 +64,7 @@ export class CommentAction extends Action {
}
}
export interface CommentActionConfig extends RequiredRichContent {
export interface CommentActionConfig extends RequiredRichContent, Footer {
/**
* Lock the comment after creation?
* */

View File

@@ -2,6 +2,7 @@ import {ActionJson, ActionConfig} from "./index";
import Action from "./index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {RuleResult} from "../Rule";
import {activityIsRemoved} from "../Utils/SnoowrapUtils";
export class RemoveAction extends Action {
getKind() {
@@ -11,7 +12,7 @@ export class RemoveAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
// issue with snoowrap typings, doesn't think prop exists on Submission
// @ts-ignore
if (item.removed === true) {
if (activityIsRemoved(item)) {
this.logger.warn('Item is already removed');
return;
}

View File

@@ -25,7 +25,7 @@ export class ReportAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
this.logger.verbose(`Contents:\r\n${renderedContent}`);
const truncatedContent = reportTrunc(renderedContent);
if(!this.dryRun) {

View File

@@ -26,7 +26,7 @@ export class UserNoteAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
if (!this.allowDuplicate) {

View File

@@ -2,11 +2,16 @@ import {Comment, Submission} from "snoowrap";
import {Logger} from "winston";
import {RuleResult} from "../Rule";
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
import {ChecksActivityState, TypedActivityStates} from "../Common/interfaces";
import Author, {AuthorOptions} from "../Author/Author";
import {isItem} from "../Utils/SnoowrapUtils";
export abstract class Action {
name?: string;
logger: Logger;
resources: SubredditResources;
authorIs: AuthorOptions;
itemIs: TypedActivityStates;
dryRun: boolean;
constructor(options: ActionOptions) {
@@ -15,12 +20,24 @@ export abstract class Action {
logger,
subredditName,
dryRun = false,
authorIs: {
include = [],
exclude = [],
} = {},
itemIs = [],
} = options;
this.name = name;
this.dryRun = dryRun;
this.resources = ResourceManager.get(subredditName) as SubredditResources;
this.logger = logger.child({labels: ['Action', this.getActionUniqueName()]});
this.logger = logger.child({labels: [`Action ${this.getActionUniqueName()}`]});
this.authorIs = {
exclude: exclude.map(x => new Author(x)),
include: include.map(x => new Author(x)),
}
this.itemIs = itemIs;
}
abstract getKind(): string;
@@ -30,7 +47,41 @@ export abstract class Action {
}
async handle(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
await this.process(item, ruleResults);
let actionRun = false;
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
if (!itemPass) {
this.logger.verbose(`Activity did not pass 'itemIs' test, Action not run`);
return;
}
const authorRun = async () => {
if (this.authorIs.include !== undefined && this.authorIs.include.length > 0) {
for (const auth of this.authorIs.include) {
if (await this.resources.testAuthorCriteria(item, auth)) {
await this.process(item, ruleResults);
return true;
}
}
this.logger.verbose('Inclusive author criteria not matched, Action not run');
return false;
}
if (!actionRun && this.authorIs.exclude !== undefined && this.authorIs.exclude.length > 0) {
for (const auth of this.authorIs.exclude) {
if (await this.resources.testAuthorCriteria(item, auth, false)) {
await this.process(item, ruleResults);
return true;
}
}
this.logger.verbose('Exclusive author criteria not matched, Action not run');
return false;
}
return null;
}
const authorRunResults = await authorRun();
if (null === authorRunResults) {
await this.process(item, ruleResults);
} else if (!authorRunResults) {
return;
}
this.logger.verbose(`${this.dryRun ? 'DRYRUN - ' : ''}Done`);
}
@@ -42,7 +93,7 @@ export interface ActionOptions extends ActionConfig {
subredditName: string;
}
export interface ActionConfig {
export interface ActionConfig extends ChecksActivityState {
/**
* An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.
*
@@ -59,6 +110,19 @@ export interface ActionConfig {
* @examples [false, true]
* */
dryRun?: boolean;
/**
* If present then these Author criteria are checked before running the Action. If criteria fails then the Action is not run.
* */
authorIs?: AuthorOptions
/**
* A list of criteria to test the state of the `Activity` against before running the Action.
*
* If any set of criteria passes the Action will be run.
*
* */
itemIs?: TypedActivityStates
}
export interface ActionJson extends ActionConfig {

View File

@@ -1,13 +1,14 @@
import Snoowrap from "snoowrap";
import Snoowrap, { Subreddit } from "snoowrap";
import {Manager} from "./Subreddit/Manager";
import winston, {Logger} from "winston";
import {argParseInt, labelledFormat, parseBool, parseFromJsonOrYamlToObject, sleep} from "./util";
import {argParseInt, labelledFormat, parseBool, parseFromJsonOrYamlToObject, parseSubredditName, sleep} from "./util";
import snoowrap from "snoowrap";
import pEvent from "p-event";
import EventEmitter from "events";
import CacheManager from './Subreddit/SubredditResources';
import dayjs, {Dayjs} from "dayjs";
import LoggedError from "./Utils/LoggedError";
import ConfigParseError from "./Utils/ConfigParseError";
const {transports} = winston;
@@ -64,6 +65,7 @@ export class App {
const myTransports = [
consoleTransport,
];
let errorTransports = [];
if (logDir !== false) {
let logPath = logDir;
@@ -80,6 +82,7 @@ export class App {
});
// @ts-ignore
myTransports.push(rotateTransport);
errorTransports.push(rotateTransport);
}
const loggerOptions = {
@@ -95,7 +98,9 @@ export class App {
debug: 5,
trace: 5,
silly: 6
}
},
exceptionHandlers: errorTransports,
rejectionHandlers: errorTransports,
};
winston.loggers.add('default', loggerOptions);
@@ -114,7 +119,7 @@ export class App {
subredditsArg = subreddits.split(',');
}
}
this.subreddits = subredditsArg;
this.subreddits = subredditsArg.map(parseSubredditName);
const creds = {
userAgent: `web:contextBot:${version}`,
@@ -145,12 +150,12 @@ export class App {
}
this.logger.info(`/u/${name} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
let subsToRun = [];
const subsToUse = subreddits.length > 0 ? subreddits : this.subreddits;
let subsToRun: Subreddit[] = [];
const subsToUse = subreddits.length > 0 ? subreddits.map(parseSubredditName) : this.subreddits;
if (subsToUse.length > 0) {
this.logger.info(`User-defined subreddit constraints detected (CLI argument or environmental variable), will try to run on: ${subsToUse.join(', ')}`);
for (const sub of subsToUse) {
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.trim().toLowerCase())
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.toLowerCase())
if (asub === undefined) {
this.logger.warn(`Will not run on ${sub} because is not modded by, or does not have appropriate permissions to mod with, for this client.`);
} else {
@@ -169,9 +174,11 @@ export class App {
// get configs for subs we want to run on and build/validate them
for (const sub of subsToRun) {
let content = undefined;
let wiki;
try {
const wiki = sub.getWikiPage(this.wikiLocation);
content = await wiki.content_md;
// @ts-ignore
wiki = await sub.getWikiPage(this.wikiLocation).fetch();
content = wiki.content_md;
} catch (err) {
this.logger.error(`[${sub.display_name_prefixed}] Could not read wiki configuration. Please ensure the page https://reddit.com${sub.url}wiki/${this.wikiLocation} exists and is readable -- error: ${err.message}`);
continue;
@@ -185,14 +192,17 @@ export class App {
const [configObj, jsonErr, yamlErr] = parseFromJsonOrYamlToObject(content);
if (configObj === undefined) {
this.logger.error(`[${sub.display_name_prefixed}] Could not parse wiki page contents as JSON or YAML`);
this.logger.error(`[${sub.display_name_prefixed}] Could not parse wiki page contents as JSON or YAML:`);
this.logger.error(jsonErr);
this.logger.error(yamlErr);
continue;
}
try {
subSchedule.push(new Manager(sub, this.client, this.logger, configObj, {dryRun: this.dryRun}));
const manager = new Manager(sub, this.client, this.logger, configObj, {dryRun: this.dryRun});
manager.lastWikiCheck = dayjs();
manager.lastWikiRevision = dayjs.unix(wiki.revision_date);
subSchedule.push(manager);
} catch (err) {
if(!(err instanceof LoggedError)) {
this.logger.error(`[${sub.display_name_prefixed}] Config was not valid`, err);
@@ -213,6 +223,17 @@ export class App {
} else {
this.logger.info(heartbeat);
}
for(const s of this.subManagers) {
try {
await s.parseConfiguration();
if(!s.running) {
s.handle();
}
} catch (err) {
s.stop();
this.logger.info('Will retry parsing config on next heartbeat...');
}
}
}
} finally {
this.heartBeating = false;
@@ -254,7 +275,7 @@ export class App {
lastErrorAt = dayjs();
if (err.message.includes('ETIMEDOUT')) {
if (err.message.includes('ETIMEDOUT') || (err.code !== undefined && err.code.includes('ETIMEDOUT'))) {
timeoutCount++;
if (timeoutCount > maxTimeoutCount) {
this.logger.error(`Timeouts (${timeoutCount}) exceeded max allowed (${maxTimeoutCount})`);

129
src/Author/Author.ts Normal file
View File

@@ -0,0 +1,129 @@
import {DurationComparor, UserNoteCriteria} from "../Rule";
import {CompareValue, CompareValueOrPercent} from "../Common/interfaces";
/**
* If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.
* @examples [{"include": [{"flairText": ["Contributor","Veteran"]}, {"isMod": true}]}]
* */
export interface AuthorOptions {
/**
* Will "pass" if any set of AuthorCriteria passes
* */
include?: AuthorCriteria[];
/**
* Only runs if `include` is not present. Will "pass" if any of set of the AuthorCriteria **does not** pass
* */
exclude?: AuthorCriteria[];
}
/**
* Criteria with which to test against the author of an Activity. The outcome of the test is based on:
*
* 1. All present properties passing and
* 2. If a property is a list then any value from the list matching
*
* @minProperties 1
* @additionalProperties false
* @examples [{"flairText": ["Contributor","Veteran"], "isMod": true, "name": ["FoxxMD", "AnotherUser"] }]
* */
export interface AuthorCriteria {
/**
* A list of reddit usernames (case-insensitive) to match against. Do not include the "u/" prefix
*
* EX to match against /u/FoxxMD and /u/AnotherUser use ["FoxxMD","AnotherUser"]
* @examples ["FoxxMD","AnotherUser"]
* */
name?: string[],
/**
* A list of (user) flair css class values from the subreddit to match against
* @examples ["red"]
* */
flairCssClass?: string[],
/**
* A list of (user) flair text values from the subreddit to match against
* @examples ["Approved"]
* */
flairText?: string[],
/**
* Is the author a moderator?
* */
isMod?: boolean,
/**
* A list of UserNote properties to check against the User Notes attached to this Author in this Subreddit (must have Toolbox enabled and used User Notes at least once)
* */
userNotes?: UserNoteCriteria[]
/**
* Test the age of the Author's account (when it was created) against this comparison
*
* The syntax is `(< OR > OR <= OR >=) <number> <unit>`
*
* * EX `> 100 days` => Passes if Author's account is older than 100 days
* * EX `<= 2 months` => Passes if Author's account is younger than or equal to 2 months
*
* Unit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)
*
* [See] https://regexr.com/609n8 for example
*
* @pattern ^\s*(?<opStr>>|>=|<|<=)\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$
* */
age?: DurationComparor
/**
* A string containing a comparison operator and a value to compare link karma against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 100` => greater than 100 link karma
* * EX `<= 75%` => link karma is less than or equal to 75% of **all karma**
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* */
linkKarma?: CompareValueOrPercent
/**
* A string containing a comparison operator and a value to compare karma against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 100` => greater than 100 comment karma
* * EX `<= 75%` => comment karma is less than or equal to 75% of **all karma**
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* */
commentKarma?: CompareValueOrPercent
totalKarma?: CompareValue
/**
* Does Author's account have a verified email?
* */
verified?: boolean
}
export class Author implements AuthorCriteria {
name?: string[];
flairCssClass?: string[];
flairText?: string[];
isMod?: boolean;
userNotes?: UserNoteCriteria[];
age?: string;
commentKarma?: string;
linkKarma?: string;
totalKarma?: string;
verified?: boolean;
constructor(options: AuthorCriteria) {
this.name = options.name;
this.flairCssClass = options.flairCssClass;
this.flairText = options.flairText;
this.isMod = options.isMod;
this.userNotes = options.userNotes;
this.age = options.age;
this.commentKarma = options.commentKarma;
this.linkKarma = options.linkKarma;
this.totalKarma = options.totalKarma;
}
}
export default Author;

View File

@@ -1,11 +1,19 @@
import {RuleSet, IRuleSet, RuleSetJson, RuleSetObjectJson} from "../Rule/RuleSet";
import {Author, AuthorOptions, IRule, Rule, RuleJSONConfig, RuleResult} from "../Rule";
import {IRule, isRuleSetResult, Rule, RuleJSONConfig, RuleResult, RuleSetResult} from "../Rule";
import Action, {ActionConfig, ActionJson} from "../Action";
import {Logger} from "winston";
import {Comment, Submission} from "snoowrap";
import {actionFactory} from "../Action/ActionFactory";
import {ruleFactory} from "../Rule/RuleFactory";
import {createAjvFactory, mergeArr, ruleNamesFromResults} from "../util";
import {
createAjvFactory,
FAIL,
mergeArr,
PASS,
resultsSummary,
ruleNamesFromResults,
truncateStringToLength
} from "../util";
import {
ChecksActivityState,
CommentState,
@@ -20,6 +28,9 @@ import * as ActionSchema from '../Schema/Action.json';
import {ActionObjectJson, RuleJson, RuleObjectJson, ActionJson as ActionTypeJson} from "../Common/types";
import {isItem} from "../Utils/SnoowrapUtils";
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
import {Author, AuthorCriteria, AuthorOptions} from "../Author/Author";
const checkLogName = truncateStringToLength(25);
export class Check implements ICheck {
actions: Action[] = [];
@@ -29,7 +40,10 @@ export class Check implements ICheck {
rules: Array<RuleSet | Rule> = [];
logger: Logger;
itemIs: TypedActivityStates;
authorIs: AuthorOptions;
authorIs: {
include: AuthorCriteria[],
exclude: AuthorCriteria[]
};
dryRun?: boolean;
resources: SubredditResources;
@@ -49,7 +63,7 @@ export class Check implements ICheck {
dryRun,
} = options;
this.logger = options.logger.child({labels: [`Check ${name}`]}, mergeArr);
this.logger = options.logger.child({labels: [`CHK ${checkLogName(name)}`]}, mergeArr);
const ajv = createAjvFactory(this.logger);
@@ -99,7 +113,10 @@ export class Check implements ICheck {
let valid = ajv.validate(ActionSchema, a);
if (valid) {
const aj = a as ActionJson;
this.actions.push(actionFactory({...aj, dryRun: this.dryRun || aj.dryRun}, this.logger, subredditName));
this.actions.push(actionFactory({
...aj,
dryRun: this.dryRun || aj.dryRun
}, this.logger, subredditName));
// @ts-ignore
a.logger = this.logger;
} else {
@@ -111,25 +128,25 @@ export class Check implements ICheck {
logSummary(type: string) {
const runStats = [];
const ruleSetCount = this.rules.reduce((x, r) => r instanceof RuleSet ? x + 1: x, 0);
const rulesInSetsCount = this.rules.reduce((x, r) => r instanceof RuleSet ? x + r.rules.length : x,0);
if(ruleSetCount > 0) {
const ruleSetCount = this.rules.reduce((x, r) => r instanceof RuleSet ? x + 1 : x, 0);
const rulesInSetsCount = this.rules.reduce((x, r) => r instanceof RuleSet ? x + r.rules.length : x, 0);
if (ruleSetCount > 0) {
runStats.push(`${ruleSetCount} Rule Sets (${rulesInSetsCount} Rules)`);
}
const topRuleCount = this.rules.reduce((x, r) => r instanceof Rule ? x + 1: x, 0);
if(topRuleCount > 0) {
const topRuleCount = this.rules.reduce((x, r) => r instanceof Rule ? x + 1 : x, 0);
if (topRuleCount > 0) {
runStats.push(`${topRuleCount} Top-Level Rules`);
}
runStats.push(`${this.actions.length} Actions`);
// not sure if this should be info or verbose
this.logger.info(`${type.toUpperCase()} (${this.condition}) => ${runStats.join(' | ')}${this.description !== undefined ? ` => ${this.description}` : ''}`);
if(this.rules.length === 0) {
this.logger.warn('No rules found -- this check will ALWAYS PASS!');
if (this.rules.length === 0 && this.itemIs.length === 0 && this.authorIs.exclude.length === 0 && this.authorIs.include.length === 0) {
this.logger.warn('No rules, item tests, or author test found -- this check will ALWAYS PASS!');
}
let ruleSetIndex = 1;
for(const r of this.rules) {
if(r instanceof RuleSet) {
for(const ru of r.rules) {
for (const r of this.rules) {
if (r instanceof RuleSet) {
for (const ru of r.rules) {
this.logger.verbose(`(Rule Set ${ruleSetIndex} ${r.condition}) => ${ru.getRuleUniqueName()}`);
}
ruleSetIndex++;
@@ -137,79 +154,92 @@ export class Check implements ICheck {
this.logger.verbose(`(Rule) => ${r.getRuleUniqueName()}`);
}
}
for(const a of this.actions) {
for (const a of this.actions) {
this.logger.verbose(`(Action) => ${a.getActionUniqueName()}`);
}
}
async runRules(item: Submission | Comment, existingResults: RuleResult[] = []): Promise<[boolean, RuleResult[]]> {
let allResults: RuleResult[] = [];
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
if(!itemPass) {
this.logger.verbose(`❌ => Item did not pass 'itemIs' test`);
return [false, allResults];
}
let authorPass = null;
if (this.authorIs.include !== undefined && this.authorIs.include.length > 0) {
for (const auth of this.authorIs.include) {
if (await this.resources.testAuthorCriteria(item, auth)) {
authorPass = true;
break;
try {
let allRuleResults: RuleResult[] = [];
let allResults: (RuleResult | RuleSetResult)[] = [];
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
if (!itemPass) {
this.logger.verbose(`${FAIL} => Item did not pass 'itemIs' test`);
return [false, allRuleResults];
}
let authorPass = null;
if (this.authorIs.include !== undefined && this.authorIs.include.length > 0) {
for (const auth of this.authorIs.include) {
if (await this.resources.testAuthorCriteria(item, auth)) {
authorPass = true;
break;
}
}
if (!authorPass) {
this.logger.verbose(`${FAIL} => Inclusive author criteria not matched`);
return Promise.resolve([false, allRuleResults]);
}
}
if(!authorPass) {
this.logger.verbose('❌ => Inclusive author criteria not matched');
return Promise.resolve([false, allResults]);
}
}
if (authorPass === null && this.authorIs.exclude !== undefined && this.authorIs.exclude.length > 0) {
for (const auth of this.authorIs.exclude) {
if (await this.resources.testAuthorCriteria(item, auth, false)) {
authorPass = true;
break;
if (authorPass === null && this.authorIs.exclude !== undefined && this.authorIs.exclude.length > 0) {
for (const auth of this.authorIs.exclude) {
if (await this.resources.testAuthorCriteria(item, auth, false)) {
authorPass = true;
break;
}
}
if (!authorPass) {
this.logger.verbose(`${FAIL} => Exclusive author criteria not matched`);
return Promise.resolve([false, allRuleResults]);
}
}
if(!authorPass) {
this.logger.verbose('❌ => Exclusive author criteria not matched');
return Promise.resolve([false, allResults]);
}
}
if(this.rules.length === 0) {
this.logger.info(`✔️ => No rules to run, check auto-passes`);
return [true, allResults];
}
if (this.rules.length === 0) {
this.logger.info(`${PASS} => No rules to run, check auto-passes`);
return [true, allRuleResults];
}
let runOne = false;
for (const r of this.rules) {
const combinedResults = [...existingResults, ...allResults];
const [passed, results] = await r.run(item, combinedResults);
allResults = allResults.concat(results);
if (passed === null) {
continue;
}
runOne = true;
if (passed) {
if (this.condition === 'OR') {
this.logger.info(`✔️ => Rules (OR): ${ruleNamesFromResults(allResults)}`);
return [true, allResults];
let runOne = false;
for (const r of this.rules) {
//let results: RuleResult | RuleSetResult;
const combinedResults = [...existingResults, ...allRuleResults];
const [passed, results] = await r.run(item, combinedResults);
if (isRuleSetResult(results)) {
allRuleResults = allRuleResults.concat(results.results);
} else {
allRuleResults = allRuleResults.concat(results as RuleResult);
}
allResults.push(results);
if (passed === null) {
continue;
}
runOne = true;
if (passed) {
if (this.condition === 'OR') {
this.logger.info(`${PASS} => Rules: ${resultsSummary(allResults, this.condition)}`);
return [true, allRuleResults];
}
} else if (this.condition === 'AND') {
this.logger.verbose(`${FAIL} => Rules: ${resultsSummary(allResults, this.condition)}`);
return [false, allRuleResults];
}
} else if (this.condition === 'AND') {
this.logger.info(`❌ => Rules (AND): ${ruleNamesFromResults(allResults)}`);
return [false, allResults];
}
if (!runOne) {
this.logger.verbose(`${FAIL} => All Rules skipped because of Author checks or itemIs tests`);
return [false, allRuleResults];
} else if (this.condition === 'OR') {
// if OR and did not return already then none passed
this.logger.verbose(`${FAIL} => Rules: ${resultsSummary(allResults, this.condition)}`);
return [false, allRuleResults];
}
// otherwise AND and did not return already so all passed
this.logger.info(`${PASS} => Rules: ${resultsSummary(allResults, this.condition)}`);
return [true, allRuleResults];
} catch (e) {
e.logged = true;
this.logger.warn(`Running rules failed due to uncaught exception`, e);
throw e;
}
if (!runOne) {
this.logger.verbose('❌ => All Rules skipped because of Author checks or itemIs tests');
return [false, allResults];
} else if(this.condition === 'OR') {
// if OR and did not return already then none passed
this.logger.verbose(`❌ => Rules (OR): ${ruleNamesFromResults(allResults)}`);
return [false, allResults];
}
// otherwise AND and did not return already so all passed
this.logger.info(`✔️ => Rules (AND) : ${ruleNamesFromResults(allResults)}`);
return [true, allResults];
}
async runActions(item: Submission | Comment, ruleResults: RuleResult[]): Promise<Action[]> {
@@ -219,9 +249,8 @@ export class Check implements ICheck {
try {
await a.handle(item, ruleResults);
runActions.push(a);
} catch(err) {
this.logger.error(`Action ${a.getActionUniqueName()} encountered an error while running`);
this.logger.error(err);
} catch (err) {
this.logger.error(`Action ${a.getActionUniqueName()} encountered an error while running`, err);
}
}
this.logger.info(`${this.dryRun ? 'DRYRUN - ' : ''}Ran Actions: ${runActions.map(x => x.getActionUniqueName()).join(' | ')}`);
@@ -309,7 +338,7 @@ export interface CommentCheckJson extends CheckJson {
itemIs?: CommentState[]
}
export type CheckStructuredJson = SubmissionCheckStructuredJson | CommentCheckStructuredJson;
export type CheckStructuredJson = SubmissionCheckStructuredJson | CommentCheckStructuredJson;
// export interface CheckStructuredJson extends CheckJson {
// rules: Array<RuleSetObjectJson | RuleObjectJson>
// actions: Array<ActionObjectJson>

View File

@@ -1,13 +1,59 @@
import {Duration} from "dayjs/plugin/duration";
/**
* An ISO 8601 Duration
* @pattern ^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$
* */
export type ISO8601 = string;
export type ActivityWindowType = ActivityWindowCriteria | DurationVal | number;
export type DurationVal = ISO8601 | DurationObject;
/**
* The criteria used to define what range of Activity to retrieve.
* A shorthand value for a DayJS duration consisting of a number value and time unit
*
* * EX `9 days`
* * EX `3 months`
* @pattern ^\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$
* */
export type DayJSShorthand = string;
export type DurationString = DayJSShorthand | ISO8601;
/**
* A value to define the range of Activities to retrieve.
*
* Acceptable values:
*
* **`ActivityWindowCriteria` object**
*
* Allows specify multiple range properties and more specific behavior
*
* **A `number` of Activities to retrieve**
*
* * EX `100` => 100 Activities
*
* *****
*
* Any of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`
*
* Acceptable values:
*
* **A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**
*
* * EX `9 days` => Range is `NOW <---> 9 days ago`
*
* **A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**
*
* * EX `{"days": 90, "minutes": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`
*
* **An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**
*
* * EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`
*
* @examples ["90 days"]
* */
export type ActivityWindowType = ActivityWindowCriteria | DurationVal | number;
export type DurationVal = DurationString | DurationObject;
/**
* Multiple properties that may be used to define what range of Activity to retrieve.
*
* May specify one, or both properties along with the `satisfyOn` property, to affect the retrieval behavior.
*
@@ -22,18 +68,23 @@ export interface ActivityWindowCriteria {
* */
count?: number,
/**
* An [ISO 8601 duration string](https://en.wikipedia.org/wiki/ISO_8601#Durations) or [Day.js duration object](https://day.js.org/docs/en/durations/creating).
* A value that specifies the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`
*
* The duration will be subtracted from the time when the rule is run to create a time range like this:
* Acceptable values:
*
* endTime = NOW <----> startTime = (NOW - `duration`)
* **A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating) time unit**
*
* EX `PT15M` or `{"minutes": 15}`
* * `endTime` = NOW (3:00PM)
* * `startTime` = (NOW - 15 minutes) = 2:45PM
* * EX `9 days` => Range is `NOW <---> 9 days ago`
*
* So look for Activities between 2:45PM and 3:00PM
* @examples ["PT15M", {"minutes": 15}]
* **A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**
*
* * EX `{"days": 90, "minutes": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`
*
* **An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**
*
* * EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`
*
* @examples ["90 days", "PT15M", {"minutes": 15}]
* */
duration?: DurationVal
@@ -42,7 +93,7 @@ export interface ActivityWindowCriteria {
*
* **If `any` then it will retrieve Activities until one of the criteria is met, whichever occurs first**
*
* EX `{count: 100, duration: {days: 90}}`:
* EX `{"count": 100, duration: "90 days"}`:
* * If 90 days of activities = 40 activities => returns 40 activities
* * If 100 activities is only 20 days => 100 activities
*
@@ -50,7 +101,7 @@ export interface ActivityWindowCriteria {
*
* Effectively, whichever criteria produces the most Activities...
*
* EX `{count: 100, duration: {days: 90}}`:
* EX `{"count": 100, duration: "90 days"}`:
* * If at 90 days of activities => 40 activities, continue retrieving results until 100 => results in >90 days of activities
* * If at 100 activities => 20 days of activities, continue retrieving results until 90 days => results in >100 activities
*
@@ -58,6 +109,28 @@ export interface ActivityWindowCriteria {
* @default any
* */
satisfyOn?: 'any' | 'all';
/**
* Filter which subreddits (case-insensitive) Activities are retrieved from.
*
* **Note:** Filtering occurs **before** `duration/count` checks are performed.
* */
subreddits?: {
/**
* Include only results from these subreddits
*
* @examples [["mealtimevideos","askscience"]]
* */
include?: string[],
/**
* Exclude any results from these subreddits
*
* **Note:** `exclude` is ignored if `include` is present
*
* @examples [["mealtimevideos","askscience"]]
* */
exclude?: string[],
}
}
/**
@@ -98,6 +171,19 @@ export interface DurationObject {
years?: number
}
export interface DurationComparison {
operator: StringOperator,
duration: Duration
}
export interface GenericComparison {
operator: StringOperator,
value: number,
isPercent: boolean,
extra?: string,
displayText: string,
}
export const windowExample: ActivityWindowType[] = [
15,
@@ -120,13 +206,7 @@ export const windowExample: ActivityWindowType[] = [
export interface ActivityWindow {
/**
* Criteria for defining what set of activities should be considered.
*
* The value of this property may be either count OR duration -- to use both write it as an `ActivityWindowCriteria`
*
* @default 15
*/
window?: ActivityWindowType,
}
@@ -142,13 +222,33 @@ export interface RichContent {
/**
* The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.
*
* If value starts with `wiki:` then the proceeding value will be used to get a wiki page
* If value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit
*
* EX `wiki:botconfig/mybot` tries to get `https://reddit.com/mySubredditExample/wiki/botconfig/mybot`
* * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`
*
* EX `this is plain text` => "this is plain text"
* If the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page
*
* EX `this is **bold** markdown text` => "this is **bold** markdown text"
* * EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`
*
* If the value starts with `url:` then the value is fetched as an external url and expects raw text returned
*
* * EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`
*
* If none of the above is used the value is treated as the raw context
*
* * EX `this is **bold** markdown text` => "this is **bold** markdown text"
*
* All Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).
*
* The following properties are always available in the template (view individual Rules to see rule-specific template data):
* ```
* item.kind => The type of Activity that was checked (comment/submission)
* item.author => The name of the Author of the Activity EX FoxxMD
* item.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x
* item.url => If the Activity is Link Sumbission then the external URL
* item.title => If the Activity is a Submission then the title of that Submission
* rules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming
* ```
*
* @examples ["This is the content of a comment/report/usernote", "this is **bold** markdown text", "wiki:botconfig/acomment" ]
* */
@@ -160,9 +260,9 @@ export interface RequiredRichContent extends RichContent {
}
/**
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
* A list of subreddits (case-insensitive) to look for.
*
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
* EX ["mealtimevideos","askscience"]
* @examples ["mealtimevideos","askscience"]
* @minItems 1
* */
@@ -170,11 +270,11 @@ export type SubredditList = string[];
export interface SubredditCriteria {
/**
* A list of subreddits (case-insensitive) to look for. Do not include "r/" prefix.
* A list of Subreddits (by name, case-insensitive) to look for.
*
* EX to match against /r/mealtimevideos and /r/askscience use ["mealtimevideos","askscience"]
* EX ["mealtimevideos","askscience"]
* @examples [["mealtimevideos","askscience"]]
* @minItems 2
* @minItems 1
* */
subreddits: string[]
}
@@ -195,49 +295,71 @@ export interface JoinCondition {
condition?: JoinOperands,
}
export type PollOn = 'unmoderated' | 'modqueue' | 'newSub' | 'newComm';
export interface PollingOptionsStrong extends PollingOptions {
limit: number,
interval: number,
}
/**
* You may specify polling options independently for submissions/comments
* @examples [{"submissions": {"limit": 10, "interval": 10000}, "comments": {"limit": 15, "interval": 10000}}]
* A configuration for where, how, and when to poll Reddit for Activities to process
*
* @examples [{"pollOn": "unmoderated","limit": 25, "interval": 20000}]
* */
export interface PollingOptions {
/**
* Polling options for submission events
* @examples [{"limit": 10, "interval": 10000}]
* What source to get Activities from. The source you choose will modify how the bots behaves so choose carefully.
*
* ### unmoderated (default)
*
* Activities that have yet to be approved/removed by a mod. This includes all modqueue (reports/spam) **and new submissions**.
*
* Use this if you want the bot to act like a regular moderator and act on anything that can be seen from mod tools.
*
* **Note:** Does NOT include new comments, only comments that are reported/filtered by Automoderator. If you want to process all unmoderated AND all new comments then use some version of `polling: ["unmoderated","newComm"]`
*
* ### modqueue
*
* Activities requiring moderator review, such as reported things and items caught by the spam filter.
*
* Use this if you only want the Bot to process reported/filtered Activities.
*
* ### newSub
*
* Get only `Submissions` that show up in `/r/mySubreddit/new`
*
* Use this if you want the bot to process Submissions only when:
*
* * they are not initially filtered by Automoderator or
* * after they have been manually approved from modqueue
*
* ## newComm
*
* Get only new `Comments`
*
* Use this if you want the bot to process Comments only when:
*
* * they are not initially filtered by Automoderator or
* * after they have been manually approved from modqueue
*
* */
submissions?: {
/**
* The number of submissions to pull from /r/subreddit/new on every request
* @default 10
* @examples [10]
* */
limit?: number,
/**
* Amount of time, in milliseconds, to wait between requests to /r/subreddit/new
*
* @default 10000
* @examples [10000]
* */
interval?: number,
},
pollOn: 'unmoderated' | 'modqueue' | 'newSub' | 'newComm'
/**
* Polling options for comment events
* @examples [{"limit": 10, "interval": 10000}]
* The maximum number of Activities to get on every request
* @default 25
* @examples [25]
* */
comments?: {
/**
* The number of new comments to pull on every request
* @default 10
* @examples [10]
* */
limit?: number,
/**
* Amount of time, in milliseconds, to wait between requests for new comments
*
* @default 10000
* @examples [10000]
* */
interval?: number,
}
limit?: number
/**
* Amount of time, in milliseconds, to wait between requests
*
* @default 20000
* @examples [20000]
* */
interval?: number,
}
export interface SubredditCacheConfig {
@@ -262,8 +384,62 @@ export interface SubredditCacheConfig {
userNotesTTL?: number;
}
export interface Footer {
/**
* Customize the footer for Actions that send replies (Comment/Ban)
*
* If `false` no footer is appended
*
* If `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).
*
* If footer is `undefined` (not set) the default footer will be used:
*
* > *****
* > This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.
*
* *****
*
* The following properties are available for [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* ```
* subName => name of subreddit Action was performed in (EX 'mealtimevideos')
* permaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x
* modmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body
* botLink => A permalink to the FAQ for this bot.
* ```
* If you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)
*
* */
footer?: false | string
}
export interface ManagerOptions {
polling?: PollingOptions
/**
* An array of sources to process Activities from
*
* Values in the array may be either:
*
* **A `string` representing the `pollOn` value to use**
*
* One of:
*
* * `unmoderated`
* * `modqueue`
* * `newSub`
* * `newComm`
*
* with the rest of the `PollingOptions` properties as defaults
*
* **A `PollingOptions` object**
*
* If you want to specify non-default preoperties
*
* ****
* If not specified the default is `["unmoderated"]`
*
* @default [["unmoderated"]]
* @example [["unmoderated","newComm"]]
* */
polling?: (string|PollingOptions)[]
/**
* Per-subreddit config for caching TTL values. If set to `false` caching is disabled.
@@ -277,8 +453,68 @@ export interface ManagerOptions {
* @examples [false,true]
* */
dryRun?: boolean;
/**
* Customize the footer for Actions that send replies (Comment/Ban). **This sets the default value for all Actions without `footer` specified in their configuration.**
*
* If `false` no footer is appended
*
* If `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).
*
* If footer is `undefined` (not set) the default footer will be used:
*
* > *****
* > This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.
*
* *****
*
* The following properties are available for [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* ```
* subName => name of subreddit Action was performed in (EX 'mealtimevideos')
* permaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x
* modmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body
* botLink => A permalink to the FAQ for this bot.
* ```
* If you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)
*
* @default undefined
* */
footer?: false | string
/*
* An alternate identifier to use in logs to identify your subreddit
*
* If your subreddit has a very long name it can make logging unwieldy. Specify a shorter name here to make log statements more readable (and shorter)
* @example ["shortName"]
* */
nickname?: string
}
/**
* A string containing a comparison operator and a value to compare against
*
* The syntax is `(< OR > OR <= OR >=) <number>`
*
* * EX `> 100` => greater than 100
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* */
export type CompareValue = string;
/**
* A string containing a comparison operator and a value to compare against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 100` => greater than 100
* * EX `<= 75%` => less than or equal to 75%
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* */
export type CompareValueOrPercent = string;
export type StringOperator = '>' | '>=' | '<' | '<=';
export interface ThresholdCriteria {
/**
* The number or percentage to trigger this criteria at
@@ -294,7 +530,7 @@ export interface ThresholdCriteria {
/**
* @examples [">",">=","<","<="]
* */
condition: '>' | '>=' | '<' | '<='
condition: StringOperator
}
export interface ChecksActivityState {
@@ -303,6 +539,8 @@ export interface ChecksActivityState {
export interface ActivityState {
removed?: boolean
filtered?: boolean
deleted?: boolean
locked?: boolean
spam?: boolean
stickied?: boolean
@@ -336,3 +574,11 @@ export interface CommentState extends ActivityState {
}
export type TypedActivityStates = SubmissionState[] | CommentState[];
export interface DomainInfo {
display: string,
domain: string,
aliases: string[],
provider?: string,
mediaType?: string
}

View File

@@ -1,7 +1,7 @@
import {RecentActivityRuleJSONConfig} from "../Rule/RecentActivityRule";
import {RepeatActivityJSONConfig} from "../Rule/SubmissionRule/RepeatActivityRule";
import {AuthorRuleJSONConfig} from "../Rule/AuthorRule";
import {AttributionJSONConfig} from "../Rule/SubmissionRule/AttributionRule";
import {AttributionJSONConfig} from "../Rule/AttributionRule";
import {FlairActionJson} from "../Action/SubmissionAction/FlairAction";
import {CommentActionJson} from "../Action/CommentAction";
import {ReportActionJson} from "../Action/ReportAction";

View File

@@ -8,7 +8,7 @@ import * as schema from './Schema/App.json';
import {JSONConfig} from "./JsonConfig";
import LoggedError from "./Utils/LoggedError";
import {CheckStructuredJson} from "./Check";
import {ManagerOptions} from "./Common/interfaces";
import {PollingOptions, PollingOptionsStrong, PollOn} from "./Common/interfaces";
import {isRuleSetJSON, RuleSetJson, RuleSetObjectJson} from "./Rule/RuleSet";
import deepEqual from "fast-deep-equal";
import {ActionJson, ActionObjectJson, RuleJson, RuleObjectJson} from "./Common/types";
@@ -37,6 +37,18 @@ export class ConfigBuilder {
this.configLogger.error('Json config was not valid. Please use schema to check validity.');
if (Array.isArray(ajv.errors)) {
for (const err of ajv.errors) {
let parts = [
`At: ${err.dataPath}`,
];
let data;
if (typeof err.data === 'string') {
data = err.data;
} else if (err.data !== null && typeof err.data === 'object' && (err.data as any).name !== undefined) {
data = `Object named '${(err.data as any).name}'`;
}
if (data !== undefined) {
parts.push(`Data: ${data}`);
}
let suffix = '';
// @ts-ignore
if (err.params.allowedValues !== undefined) {
@@ -44,7 +56,23 @@ export class ConfigBuilder {
suffix = err.params.allowedValues.join(', ');
suffix = ` [${suffix}]`;
}
this.configLogger.error(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`);
parts.push(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`);
// if we have a reference in the description parse it out so we can log it here for context
if(err.parentSchema !== undefined && err.parentSchema.description !== undefined) {
const desc = err.parentSchema.description as string;
const seeIndex = desc.indexOf('[See]');
if(seeIndex !== -1) {
let newLineIndex: number | undefined = desc.indexOf('\n', seeIndex);
if(newLineIndex === -1) {
newLineIndex = undefined;
}
const seeFragment = desc.slice(seeIndex + 5, newLineIndex);
parts.push(`See:${seeFragment}`);
}
}
this.configLogger.error(`Schema Error:\r\n${parts.join('\r\n')}`);
}
}
throw new LoggedError('Config schema validity failure');
@@ -56,14 +84,14 @@ export class ConfigBuilder {
let namedActions: Map<string, ActionObjectJson> = new Map();
const {checks = []} = config;
for (const c of checks) {
const { rules = [] } = c;
const {rules = []} = c;
namedRules = extractNamedRules(rules, namedRules);
namedActions = extractNamedActions(c.actions, namedActions);
}
const structuredChecks: CheckStructuredJson[] = [];
for (const c of checks) {
const { rules = [] } = c;
const {rules = []} = c;
const strongRules = insertNamedRules(rules, namedRules);
const strongActions = insertNamedActions(c.actions, namedActions);
const strongCheck = {...c, rules: strongRules, actions: strongActions} as CheckStructuredJson;
@@ -74,6 +102,23 @@ export class ConfigBuilder {
}
}
export const buildPollingOptions = (values: (string | PollingOptions)[]): PollingOptionsStrong[] => {
let opts: PollingOptionsStrong[] = [];
for (const v of values) {
if (typeof v === 'string') {
opts.push({pollOn: v as PollOn, interval: 10000, limit: 25});
} else {
const {
pollOn: p,
interval = 20000,
limit = 25
} = v;
opts.push({pollOn: p as PollOn, interval, limit});
}
}
return opts;
}
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) {

440
src/Rule/AttributionRule.ts Normal file
View File

@@ -0,0 +1,440 @@
import {SubmissionRule, SubmissionRuleJSONConfig} from "./SubmissionRule";
import {ActivityWindowType, DomainInfo, ReferenceSubmission} from "../Common/interfaces";
import {Rule, RuleOptions, RuleResult} from "./index";
import Submission from "snoowrap/dist/objects/Submission";
import {getAttributionIdentifier} from "../Utils/SnoowrapUtils";
import dayjs from "dayjs";
import {
comparisonTextOp,
FAIL,
formatNumber,
parseGenericValueOrPercentComparison,
parseSubredditName,
PASS
} from "../util";
import { Comment } from "snoowrap/dist/objects";
import SimpleError from "../Utils/SimpleError";
export interface AttributionCriteria {
/**
* A string containing a comparison operator and a value to compare comments against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 12` => greater than 12 activities originate from same attribution
* * EX `<= 10%` => less than 10% of all Activities have the same attribution
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* @default "> 10%"
* */
threshold: 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 that must exist for this criteria to run
* @default 5
* */
minActivityCount?: number
/**
* A list of domains whose Activities will be tested against `threshold`.
*
* If this is present then `aggregateOn` is ignored.
*
* The values are tested as partial strings so you do not need to include full URLs, just the part that matters.
*
* EX `["youtube"]` will match submissions with the domain `https://youtube.com/c/aChannel`
* EX `["youtube.com/c/bChannel"]` will NOT match submissions with the domain `https://youtube.com/c/aChannel`
*
* If you wish to aggregate on self-posts for a subreddit use the syntax `self.[subreddit]` EX `self.AskReddit`
*
* **If this Rule is part of a Check for a Submission and you wish to aggregate on the domain of the Submission use the special string `AGG:SELF`**
*
* If nothing is specified or list is empty (default) aggregate using `aggregateOn`
*
* @default [[]]
* */
domains?: string[],
/**
* Set to `true` if you wish to combine all of the Activities from `domains` to test against `threshold` instead of testing each `domain` individually
*
* @default false
* @examples [false]
* */
domainsCombined?: boolean,
/**
* Only include Activities from this list of Subreddits (by name, case-insensitive)
*
*
* EX `["mealtimevideos","askscience"]`
* @examples ["mealtimevideos","askscience"]
* @minItems 1
* */
include?: string[],
/**
* Do not include Activities from this list of Subreddits (by name, case-insensitive)
*
* Will be ignored if `include` is present.
*
* EX `["mealtimevideos","askscience"]`
* @examples ["mealtimevideos","askscience"]
* @minItems 1
* */
exclude?: string[],
/**
* If `domains` is not specified this list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`
*
* * If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)
* * If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or reddit image/video (i.redd.it / v.redd.it)
* * If `link` is included then aggregate author's submission history which is external links but not media
*
* If nothing is specified or list is empty (default) all domains are aggregated
*
* @default undefined
* @examples [[]]
* */
aggregateOn?: ('media' | 'self' | 'link')[],
/**
* Should the criteria consolidate recognized media domains into the parent domain?
*
* Submissions to major media domains (youtube, vimeo) can be identified by individual Channel/Author...
*
* * If `false` then domains will be aggregated at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)
* * If `true` then then media domains will be consolidated at domain level and then aggregated IE youtube.com (5 counts)
*
* @default false
* @examples [false]
* */
consolidateMediaDomains?: boolean
name?: string
}
const SUBMISSION_DOMAIN = 'AGG:SELF';
const defaultCriteria = [{threshold: '10%', window: 100}];
interface DomainAgg {
info: DomainInfo,
count: number
}
export class AttributionRule extends Rule {
criteria: AttributionCriteria[];
criteriaJoin: 'AND' | 'OR';
constructor(options: AttributionOptions) {
super(options);
const {
criteria = defaultCriteria,
criteriaJoin = 'OR',
} = options || {};
this.criteria = criteria;
this.criteriaJoin = criteriaJoin;
if (this.criteria.length === 0) {
throw new Error('Must provide at least one AttributionCriteria');
}
}
getKind(): string {
return "Attr";
}
protected getSpecificPremise(): object {
return {
criteria: this.criteria,
criteriaJoin: this.criteriaJoin,
}
}
protected async process(item: Comment | Submission): Promise<[boolean, RuleResult]> {
let criteriaResults = [];
for (const criteria of this.criteria) {
const {
threshold = '> 10%',
window,
thresholdOn = 'all',
minActivityCount = 10,
aggregateOn = [],
consolidateMediaDomains = false,
domains = [],
domainsCombined = false,
include: includeRaw = [],
exclude: excludeRaw = [],
} = criteria;
const include = includeRaw.map(x => parseSubredditName(x).toLowerCase());
const exclude = excludeRaw.map(x => parseSubredditName(x).toLowerCase());
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(threshold);
let activities = thresholdOn === 'submissions' ? await this.resources.getAuthorSubmissions(item.author, {window: window}) : await this.resources.getAuthorActivities(item.author, {window: window});
activities = activities.filter(act => {
if (include.length > 0) {
return include.some(x => x === act.subreddit.display_name.toLowerCase());
} else if (exclude.length > 0) {
return !exclude.some(x => x === act.subreddit.display_name.toLowerCase())
}
return true;
});
let activityTotal = 0;
let firstActivity, lastActivity;
if(activities.length === 0) {
this.logger.debug(`No activities retrieved for criteria`);
continue;
}
activityTotal = activities.length;
firstActivity = activities[0];
lastActivity = activities[activities.length - 1];
const activityTotalWindow = dayjs.duration(dayjs(firstActivity.created_utc * 1000).diff(dayjs(lastActivity.created_utc * 1000)));
if (activities.length < minActivityCount) {
criteriaResults.push({criteria, activityTotal, activityTotalWindow, triggered: false, aggDomains: [], minCountMet: false});
this.logger.debug(`${activities.length } activities retrieved was less than min activities required to run criteria (${minActivityCount})`);
continue;
}
const realDomains: DomainInfo[] = domains.map(x => {
if(x === SUBMISSION_DOMAIN) {
if(!(item instanceof Submission)) {
throw new SimpleError('Cannot run Attribution Rule with the domain SELF:AGG on a Comment');
}
return getAttributionIdentifier(item, consolidateMediaDomains);
}
return {display: x, domain: x, aliases: [x]};
});
const realDomainIdents = realDomains.map(x => x.aliases).flat(1).map(x => x.toLowerCase());
const submissions: Submission[] = thresholdOn === 'submissions' ? activities as Submission[] : activities.filter(x => x instanceof Submission) as Submission[];
const aggregatedSubmissions = submissions.reduce((acc: Map<string, DomainAgg>, sub) => {
const domainInfo = getAttributionIdentifier(sub, consolidateMediaDomains)
let domainType = 'link';
if(sub.secure_media !== undefined && sub.secure_media !== null) {
domainType = 'media';
} else if((sub.is_self || sub.is_video || sub.domain === 'i.redd.it')) {
domainType = 'self';
}
if(realDomains.length === 0 && aggregateOn.length !== 0) {
if(domainType === 'media' && !aggregateOn.includes('media')) {
return acc;
}
if(domainType === 'self' && !aggregateOn.includes('self')) {
return acc;
}
if(domainType === 'link' && !aggregateOn.includes('link')) {
return acc;
}
}
if(realDomains.length > 0) {
if(domainInfo.aliases.map(x => x.toLowerCase()).some(x => realDomainIdents.includes(x))) {
const domainAgg = acc.get(domainInfo.display) || {info: domainInfo, count: 0};
acc.set(domainInfo.display, {...domainAgg, count: domainAgg.count + 1});
}
} else {
const domainAgg = acc.get(domainInfo.display) || {info: domainInfo, count: 0};
acc.set(domainInfo.display, {...domainAgg, count: domainAgg.count + 1});
}
return acc;
}, new Map());
let aggDomains = [];
if(domainsCombined) {
let combinedCount = 0;
let domains = [];
let triggered = false;
for (const [domain, dAgg] of aggregatedSubmissions) {
domains.push(domain);
combinedCount += dAgg.count;
}
if(isPercent) {
triggered = comparisonTextOp(combinedCount / activityTotal, operator, (value/100));
}
else {
triggered = comparisonTextOp(combinedCount, operator, value);
}
const combinedDomain = Array.from(aggregatedSubmissions.values()).map(x => x.info.domain).join(' and ');
const combinedDisplay = Array.from(aggregatedSubmissions.values()).map(x => `${x.info.display}${x.info.provider !== undefined ? ` (${x.info.provider})` : ''}`).join(' and ');
aggDomains.push({
domain: {display: combinedDisplay, domain: combinedDomain, aliases: [combinedDomain]},
count: combinedCount,
percent: Math.round((combinedCount / activityTotal) * 100),
triggered,
});
} else {
for (const [domain, dAgg] of aggregatedSubmissions) {
let triggered = false;
if(isPercent) {
triggered = comparisonTextOp(dAgg.count / activityTotal, operator, (value/100));
}
else {
triggered = comparisonTextOp(dAgg.count, operator, value);
}
aggDomains.push({
domain: dAgg.info,
count: dAgg.count,
percent: Math.round((dAgg.count / activityTotal) * 100),
triggered,
});
}
}
criteriaResults.push({criteria, activityTotal, activityTotalWindow, aggDomains, minCountMet: true});
}
let criteriaMeta = false;
if (this.criteriaJoin === 'OR') {
criteriaMeta = criteriaResults.some(x => x.aggDomains.length > 0 && x.aggDomains.some(y => y.triggered === true));
} else {
criteriaMeta = criteriaResults.every(x => x.aggDomains.length > 0 && x.aggDomains.some(y => y.triggered === true));
}
let usableCriteria = criteriaResults.filter(x => x.aggDomains.length > 0 && x.aggDomains.some(y => y.triggered === true));
if (usableCriteria.length === 0) {
usableCriteria = criteriaResults.filter(x => x.aggDomains.length > 0)
}
// probably none hit min count then
if(criteriaResults.every(x => x.minCountMet === false)) {
const result = `${FAIL} No criteria had their min activity count met`;
this.logger.verbose(result);
return Promise.resolve([false, this.getResult(false, {result})]);
}
let result;
const refCriteriaResults = usableCriteria.find(x => x !== undefined);
if(refCriteriaResults === undefined) {
result = `${FAIL} No criteria results found??`;
return Promise.resolve([false, this.getResult(false, {result})])
}
const {
aggDomains = [],
activityTotal,
activityTotalWindow,
criteria: {threshold, window}
} = refCriteriaResults;
const largestCount = aggDomains.reduce((acc, curr) => Math.max(acc, curr.count), 0);
const largestPercent = aggDomains.reduce((acc, curr) => Math.max(acc, curr.percent), 0);
const smallestCount = aggDomains.reduce((acc, curr) => Math.min(acc, curr.count), aggDomains[0].count);
const smallestPercent = aggDomains.reduce((acc, curr) => Math.min(acc, curr.percent), aggDomains[0].percent);
const windowText = typeof window === 'number' ? `${activityTotal} Items` : activityTotalWindow.humanize();
const countRange = smallestCount === largestCount ? largestCount : `${smallestCount} - ${largestCount}`
const percentRange = formatNumber(smallestPercent, {toFixed: 0}) === formatNumber(largestPercent, {toFixed: 0}) ? `${largestPercent}%` : `${smallestPercent}% - ${largestPercent}%`
let data: any = {};
const resultAgnostic = `met the threshold of ${threshold}, with ${countRange} (${percentRange}) of ${activityTotal} Total -- window: ${windowText}`;
if(criteriaMeta) {
result = `${PASS} ${aggDomains.length} Attribution(s) ${resultAgnostic}`;
data = {
triggeredDomainCount: aggDomains.length,
activityTotal,
largestCount,
largestPercent: `${largestPercent}%`,
smallestCount,
smallestPercent: `${smallestPercent}%`,
countRange,
percentRange,
domains: aggDomains.map(x => x.domain.domain),
domainsDelim: aggDomains.map(x => x.domain.domain).join(', '),
titles: aggDomains.map(x => `${x.domain.display}${x.domain.provider !== undefined ? ` (${x.domain.provider})` :''}`),
titlesDelim: aggDomains.map(x => `${x.domain.display}${x.domain.provider !== undefined ? ` (${x.domain.provider})` :''}`).join(', '),
threshold: threshold,
window: windowText
};
} else {
result = `${FAIL} No Attributions ${resultAgnostic}`;
}
this.logger.verbose(result);
return Promise.resolve([criteriaMeta, this.getResult(criteriaMeta, {
result,
data,
})]);
}
}
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'
}
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):
*
* ```
* triggeredDomainCount => Number of domains that met the threshold
* activityTotal => Number of Activities considered from window
* window => The date range of the Activities considered
* largestCount => The count from the largest aggregated domain
* largestPercentage => The percentage of Activities the largest aggregated domain comprises
* smallestCount => The count from the smallest aggregated domain
* smallestPercentage => The percentage of Activities the smallest aggregated domain comprises
* countRange => A convenience string displaying "smallestCount - largestCount" or just one number if both are the same
* percentRange => A convenience string displaying "smallestPercentage - largestPercentage" or just one percentage if both are the same
* domains => An array of all the domain URLs that met the threshold
* domainsDelim => A comma-delimited string of all the domain URLs that met the threshold
* titles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => "My Youtube Channel Title")
* titlesDelim => A comma-delimited string of all the domain friendly-names
* 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

@@ -1,7 +1,7 @@
import {Author, AuthorOptions, AuthorCriteria, Rule, RuleJSONConfig, RuleOptions, RuleResult} from "./index";
import {Rule, RuleJSONConfig, RuleOptions, RuleResult} from "./index";
import {Comment} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {testAuthorCriteria} from "../Utils/SnoowrapUtils";
import {Author, AuthorCriteria} from "../Author/Author";
/**
* Checks the author of the Activity against AuthorCriteria. This differs from a Rule's AuthorOptions as this is a full Rule and will only pass/fail, not skip.
@@ -43,7 +43,7 @@ export class AuthorRule extends Rule {
}
getKind(): string {
return "author";
return "Author";
}
protected getSpecificPremise(): object {
@@ -53,21 +53,21 @@ export class AuthorRule extends Rule {
};
}
protected async process(item: Comment | Submission): Promise<[boolean, RuleResult[]]> {
protected async process(item: Comment | Submission): Promise<[boolean, RuleResult]> {
if (this.include.length > 0) {
for (const auth of this.include) {
if (await this.resources.testAuthorCriteria(item, auth)) {
return Promise.resolve([true, [this.getResult(true)]]);
return Promise.resolve([true, this.getResult(true)]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
for (const auth of this.exclude) {
if (await this.resources.testAuthorCriteria(item, auth, false)) {
return Promise.resolve([true, [this.getResult(true)]]);
return Promise.resolve([true, this.getResult(true)]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
}

View File

@@ -1,10 +1,17 @@
import {ActivityWindowType, ThresholdCriteria} from "../Common/interfaces";
import {ActivityWindowType, CompareValueOrPercent, ThresholdCriteria} from "../Common/interfaces";
import {Rule, RuleJSONConfig, RuleOptions, RuleResult} from "./index";
import Submission from "snoowrap/dist/objects/Submission";
import {getAuthorActivities} from "../Utils/SnoowrapUtils";
import dayjs from "dayjs";
import {comparisonTextOp, formatNumber, percentFromString} from "../util";
import {
comparisonTextOp,
FAIL,
formatNumber,
parseGenericValueOrPercentComparison, parseSubredditName,
PASS,
percentFromString
} from "../util";
export interface CommentThresholdCriteria extends ThresholdCriteria {
/**
@@ -20,11 +27,34 @@ export interface CommentThresholdCriteria extends ThresholdCriteria {
* */
export interface HistoryCriteria {
submission?: ThresholdCriteria
comment?: CommentThresholdCriteria
/**
* Window defining Activities to consider (both Comment/Submission)
*/
* A string containing a comparison operator and a value to compare submissions against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 100` => greater than 100 submissions
* * EX `<= 75%` => submissions are equal to or less than 75% of all Activities
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* */
submission?: CompareValueOrPercent
/**
* A string containing a comparison operator and a value to compare comments against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign] [OP]`
*
* * EX `> 100` => greater than 100 comments
* * EX `<= 75%` => comments are equal to or less than 75% of all Activities
*
* If your string also contains the text `OP` somewhere **after** `<number>[percent sign]`...:
*
* * EX `> 100 OP` => greater than 100 comments as OP
* * EX `<= 25% as OP` => Comments as OP were less then or equal to 25% of **all Comments**
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* */
comment?: CompareValueOrPercent
window: ActivityWindowType
/**
@@ -37,7 +67,7 @@ export interface HistoryCriteria {
export class HistoryRule extends Rule {
criteria: HistoryCriteria[];
criteriaJoin: 'AND' | 'OR';
condition: 'AND' | 'OR';
include: string[];
exclude: string[];
@@ -45,18 +75,18 @@ export class HistoryRule extends Rule {
super(options);
const {
criteria,
criteriaJoin = 'OR',
condition = 'OR',
include = [],
exclude = [],
} = options || {};
this.criteria = criteria;
this.criteriaJoin = criteriaJoin;
this.condition = condition;
if (this.criteria.length === 0) {
throw new Error('Must provide at least one HistoryCriteria');
}
this.include = include.map(x => x.toLowerCase());
this.exclude = exclude.map(x => x.toLowerCase());
this.include = include.map(x => parseSubredditName(x).toLowerCase());
this.exclude = exclude.map(x => parseSubredditName(x).toLowerCase());
}
getKind(): string {
@@ -71,7 +101,7 @@ export class HistoryRule extends Rule {
}
}
protected async process(item: Submission): Promise<[boolean, RuleResult[]]> {
protected async process(item: Submission): Promise<[boolean, RuleResult]> {
// TODO reuse activities between ActivityCriteria to reduce api calls
let criteriaResults = [];
@@ -108,31 +138,32 @@ export class HistoryRule extends Rule {
let commentTrigger = undefined;
if(comment !== undefined) {
const {threshold, condition, asOp = false} = comment;
if(typeof threshold === 'string') {
const per = percentFromString(threshold);
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(comment);
const asOp = extra.toLowerCase().includes('op');
if(isPercent) {
const per = value / 100;
if(asOp) {
commentTrigger = comparisonTextOp(opTotal / commentTotal, condition, per);
commentTrigger = comparisonTextOp(opTotal / commentTotal, operator, per);
} else {
commentTrigger = comparisonTextOp(commentTotal / activityTotal, condition, per);
commentTrigger = comparisonTextOp(commentTotal / activityTotal, operator, per);
}
} else {
if(asOp) {
commentTrigger = comparisonTextOp(opTotal, condition, threshold);
commentTrigger = comparisonTextOp(opTotal, operator, value);
} else {
commentTrigger = comparisonTextOp(commentTotal, condition, threshold);
commentTrigger = comparisonTextOp(commentTotal, operator, value);
}
}
}
let submissionTrigger = undefined;
if(submission !== undefined) {
const {threshold, condition, } = submission;
if(typeof threshold === 'string') {
const per = percentFromString(threshold);
submissionTrigger = comparisonTextOp(submissionTotal / activityTotal, condition, per);
const {operator, value, isPercent} = parseGenericValueOrPercentComparison(submission);
if(isPercent) {
const per = value / 100;
submissionTrigger = comparisonTextOp(submissionTotal / activityTotal, operator, per);
} else {
submissionTrigger = comparisonTextOp(submissionTotal, condition, threshold);
submissionTrigger = comparisonTextOp(submissionTotal, operator, value);
}
}
@@ -148,86 +179,104 @@ export class HistoryRule extends Rule {
submissionTotal,
commentTotal,
opTotal,
triggered: submissionTrigger === true || commentTrigger === true
submissionTrigger,
commentTrigger,
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true)
});
}
let criteriaMeta = false;
if (this.criteriaJoin === 'OR') {
criteriaMeta = criteriaResults.some(x => x.triggered);
let criteriaMet = false;
let failCriteriaResult: string = '';
if (this.condition === 'OR') {
criteriaMet = criteriaResults.some(x => x.triggered);
if(!criteriaMet) {
failCriteriaResult = `${FAIL} No criteria was met`;
}
} else {
criteriaMeta = criteriaResults.every(x => x.triggered);
criteriaMet = criteriaResults.every(x => x.triggered);
if(!criteriaMet) {
if(criteriaResults.some(x => x.triggered)) {
const met = criteriaResults.filter(x => x.triggered);
failCriteriaResult = `${FAIL} ${met.length} out of ${criteriaResults.length} criteria met but Rule required all be met. Set log level to debug to see individual results`;
const results = criteriaResults.map(x => this.generateResultDataFromCriteria(x, true));
this.logger.debug(`\r\n ${results.map(x => x.result).join('\r\n')}`);
} else {
failCriteriaResult = `${FAIL} No criteria was met`;
}
}
}
if (criteriaMeta) {
if(criteriaMet) {
// use first triggered criteria found
const refCriteriaResults = criteriaResults.find(x => x.triggered);
if (refCriteriaResults !== undefined) {
const {
activityTotal,
activityTotalWindow,
submissionTotal,
commentTotal,
opTotal,
criteria: {
comment: {
threshold: cthresh,
condition: ccond,
asOp
} = {},
submission: {
threshold: sthresh,
condition: scond,
} = {},
window,
},
criteria,
} = refCriteriaResults;
const data: any = {
activityTotal,
submissionTotal,
commentTotal,
opTotal,
commentPercent: formatNumber((commentTotal/activityTotal)*100),
submissionPercent: formatNumber((submissionTotal/activityTotal)*100),
opPercent: formatNumber((opTotal/commentTotal)*100),
criteria,
window: typeof window === 'number' ? `${activityTotal} Items` : activityTotalWindow.humanize(true)
};
let thresholdSummary = [];
let submissionSummary;
let commentSummary;
if(sthresh !== undefined) {
const suffix = typeof sthresh === 'number' ? 'Items' : `(${formatNumber((submissionTotal/activityTotal)*100)}%) of ${activityTotal} Total`;
submissionSummary = `Submissions (${submissionTotal}) were ${scond}${sthresh} ${suffix}`;
data.submissionSummary = submissionSummary;
thresholdSummary.push(submissionSummary);
}
if(cthresh !== undefined) {
const totalType = asOp ? 'Comments' : 'Activities'
const countType = asOp ? 'Comments as OP' : 'Comments';
const suffix = typeof cthresh === 'number' ? 'Items' : `(${asOp ? formatNumber((opTotal/commentTotal)*100) : formatNumber((commentTotal/activityTotal)*100)}%) of ${activityTotal} Total ${totalType}`;
commentSummary = `${countType} (${asOp ? opTotal : commentTotal}) were ${ccond}${cthresh} ${suffix}`;
data.commentSummary = commentSummary;
thresholdSummary.push(commentSummary);
}
data.thresholdSummary = thresholdSummary.join(' and ');
const result = `${thresholdSummary} (${data.window})`;
this.logger.verbose(result);
return Promise.resolve([true, [this.getResult(true, {
result,
data,
})]]);
}
const resultData = this.generateResultDataFromCriteria(refCriteriaResults);
this.logger.verbose(`${PASS} ${resultData.result}`);
return Promise.resolve([true, this.getResult(true, resultData)]);
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false, {result: failCriteriaResult})]);
}
protected generateResultDataFromCriteria(results: any, includePassFailSymbols = false) {
const {
activityTotal,
activityTotalWindow,
submissionTotal,
commentTotal,
opTotal,
criteria: {
comment,
submission,
window,
},
criteria,
triggered,
submissionTrigger,
commentTrigger,
} = results;
const data: any = {
activityTotal,
submissionTotal,
commentTotal,
opTotal,
commentPercent: formatNumber((commentTotal/activityTotal)*100),
submissionPercent: formatNumber((submissionTotal/activityTotal)*100),
opPercent: formatNumber((opTotal/commentTotal)*100),
criteria,
window: typeof window === 'number' ? `${activityTotal} Items` : activityTotalWindow.humanize(true),
triggered,
submissionTrigger,
commentTrigger,
};
let thresholdSummary = [];
let submissionSummary;
let commentSummary;
if(submission !== undefined) {
const {operator, value, isPercent, displayText} = parseGenericValueOrPercentComparison(submission);
const suffix = !isPercent ? 'Items' : `(${formatNumber((submissionTotal/activityTotal)*100)}%) of ${activityTotal} Total`;
submissionSummary = `${includePassFailSymbols ? `${submissionTrigger ? PASS : FAIL} ` : ''}Submissions (${submissionTotal}) were${submissionTrigger ? '' : ' not'} ${displayText} ${suffix}`;
data.submissionSummary = submissionSummary;
thresholdSummary.push(submissionSummary);
}
if(comment !== undefined) {
const {operator, value, isPercent, displayText, extra = ''} = parseGenericValueOrPercentComparison(comment);
const asOp = extra.toLowerCase().includes('op');
const totalType = asOp ? 'Comments' : 'Activities'
const countType = asOp ? 'Comments as OP' : 'Comments';
const suffix = !isPercent ? 'Items' : `(${asOp ? formatNumber((opTotal/commentTotal)*100) : formatNumber((commentTotal/activityTotal)*100)}%) of ${activityTotal} Total ${totalType}`;
commentSummary = `${includePassFailSymbols ? `${commentTrigger ? PASS : FAIL} ` : ''}${countType} (${asOp ? opTotal : commentTotal}) were${commentTrigger ? '' : ' not'} ${displayText} ${suffix}`;
data.commentSummary = commentSummary;
thresholdSummary.push(commentSummary);
}
data.thresholdSummary = thresholdSummary.join(' and ');
const result = `${thresholdSummary} (${data.window})`;
return {result, data};
}
}
@@ -247,24 +296,20 @@ interface HistoryConfig {
* * If `OR` then any set of Criteria that pass will trigger the Rule
* * If `AND` then all Criteria sets must pass to trigger the Rule
* */
criteriaJoin?: 'AND' | 'OR'
condition?: 'AND' | 'OR'
/**
* Only include Submissions from this list of Subreddits.
* Only include Submissions from this list of Subreddits (by name, case-insensitive)
*
* 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"]
* EX `["mealtimevideos","askscience"]`
* @examples ["mealtimevideos","askscience"]
* @minItems 1
* */
include?: string[],
/**
* Do not include Submissions from this list of Subreddits.
* Do not include Submissions from this list of Subreddits (by name, case-insensitive)
*
* 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"]
* EX `["mealtimevideos","askscience"]`
* @examples ["mealtimevideos","askscience"]
* @minItems 1
* */

View File

@@ -1,7 +1,13 @@
import {Rule, RuleJSONConfig, RuleOptions, RulePremise, RuleResult} from "./index";
import {Comment, VoteableContent} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {activityWindowText, parseUsableLinkIdentifier} from "../util";
import {
activityWindowText,
comparisonTextOp, FAIL, formatNumber,
parseGenericValueOrPercentComparison, parseSubredditName,
parseUsableLinkIdentifier,
PASS
} from "../util";
import {
ActivityWindow,
ActivityWindowCriteria,
@@ -32,7 +38,7 @@ export class RecentActivityRule extends Rule {
}
getKind(): string {
return 'Recent Activity';
return 'Recent';
}
getSpecificPremise(): object {
@@ -44,7 +50,7 @@ export class RecentActivityRule extends Rule {
}
}
async process(item: Submission | Comment): Promise<[boolean, RuleResult[]]> {
async process(item: Submission | Comment): Promise<[boolean, RuleResult]> {
let activities;
switch (this.lookAt) {
@@ -59,7 +65,6 @@ export class RecentActivityRule extends Rule {
break;
}
let viableActivity = activities;
if (this.useSubmissionAsReference) {
if (!(item instanceof Submission)) {
@@ -84,74 +89,83 @@ export class RecentActivityRule extends Rule {
grouped[s] = (grouped[s] || []).concat(activity);
return grouped;
}, {} as Record<string, (Submission | Comment)[]>);
let triggeredPerSub = [];
const summaries = [];
let totalTriggeredOn;
for (const triggerSet of this.thresholds) {
triggeredPerSub = [];
let currCount = 0;
let presentSubs = [];
const {count: subCount, totalCount, subreddits = []} = triggerSet;
for (const sub of subreddits) {
const presentSubs = [];
const {threshold = '>= 1', subreddits = []} = triggerSet;
for (const sub of subreddits.map(x => parseSubredditName(x))) {
const isub = sub.toLowerCase();
const {[isub]: tSub = []} = groupedActivity;
if(tSub.length > 0) {
if (tSub.length > 0) {
currCount += tSub.length;
presentSubs.push(sub);
if (subCount !== undefined && tSub.length >= subCount) {
triggeredPerSub.push({subreddit: sub, count: tSub.length, threshold: subCount});
}
}
}
if(totalCount !== undefined && currCount >= totalCount) {
totalTriggeredOn = {subreddits: presentSubs, count: currCount, threshold: totalCount};
const {operator, value, isPercent} = parseGenericValueOrPercentComparison(threshold);
let sum = {subsWithActivity: presentSubs, subreddits, count: currCount, threshold, triggered: false, testValue: currCount.toString()};
if (isPercent) {
sum.testValue = `${formatNumber((currCount / viableActivity.length) * 100)}%`;
if (comparisonTextOp(currCount / viableActivity.length, operator, value / 100)) {
sum.triggered = true;
totalTriggeredOn = sum;
}
} else if (comparisonTextOp(currCount, operator, value)) {
sum.triggered = true;
totalTriggeredOn = sum;
}
summaries.push(sum);
// if either trigger condition is hit end the iteration early
if(triggeredPerSub.length > 0 || totalTriggeredOn !== undefined) {
if (totalTriggeredOn !== undefined) {
break;
}
}
if (triggeredPerSub.length > 0 || totalTriggeredOn !== undefined) {
let resultArr = [];
const data: any = {};
if(triggeredPerSub.length > 0) {
data.perSubCount = triggeredPerSub.length;
data.perSubTotal = triggeredPerSub.reduce((acc, x) => acc + x.count, 0);
data.perSubSubredditsSummary = triggeredPerSub.map(x => x.subreddit).join(', ');
data.perSubSummary = triggeredPerSub.map(x => `${x.subreddit}(${x.count})`).join(', ');
data.perSubThreshold = triggeredPerSub[0].threshold;
resultArr.push(`${triggeredPerSub.length} subs have >${triggeredPerSub[0].threshold} activities (${data.perSubTotal} Total)`);
}
if(totalTriggeredOn !== undefined) {
data.totalCount = totalTriggeredOn.count;
data.totalSubredditsCount = totalTriggeredOn.subreddits.length;
data.totalSubredditsSummary = totalTriggeredOn.subreddits.join(', ')
data.totalThreshold = totalTriggeredOn.threshold;
data.totalSummary = `${data.totalCount} (>${totalTriggeredOn.threshold}) activities over ${totalTriggeredOn.subreddits.length} subreddits`;
resultArr.push(data.totalSummary);
}
let summary;
if(resultArr.length === 2) {
// need a shortened summary
summary = `${data.perSubCount} per-sub triggers (${data.perSubThreshold}) and ${data.totalCount} total (${data.totalThreshold})`
} else {
summary = resultArr[0];
}
const result = resultArr.join(' and ')
let result = '';
if (totalTriggeredOn !== undefined) {
const resultData = this.generateResultData(totalTriggeredOn, viableActivity);
result = `${PASS} ${resultData.result}`;
this.logger.verbose(result);
return Promise.resolve([true, [this.getResult(true, {
result,
data: {
window: typeof this.window === 'number' ? `${activities.length} Items` : activityWindowText(viableActivity),
triggeredOn: triggeredPerSub,
summary,
subSummary: data.totalSubredditsSummary|| data.perSubSubredditsSummary,
subCount: data.totalSubredditsCount || data.perSubCount,
totalCount: data.totalCount || data.perSubTotal
}
})]]);
return Promise.resolve([true, this.getResult(true, resultData)]);
} else if(summaries.length === 1) {
// can display result if its only one summary otherwise need to log to debug
const res = this.generateResultData(summaries[0], viableActivity);
result = `${FAIL} ${res.result}`;
} else {
result = `${FAIL} No criteria was met. Use 'debug' to see individual results`;
this.logger.debug(`\r\n ${summaries.map(x => this.generateResultData(x, viableActivity).result).join('\r\n')}`);
}
return Promise.resolve([false, [this.getResult(false)]]);
this.logger.verbose(result);
return Promise.resolve([false, this.getResult(false, {result})]);
}
generateResultData(summary: any, activities: (Submission | Comment)[] = []) {
const {
count,
testValue,
subreddits = [],
subsWithActivity = [],
threshold,
triggered
} = summary;
const relevantSubs = subsWithActivity.length === 0 ? subreddits : subsWithActivity;
const totalSummary = `${testValue} activities over ${relevantSubs.length} subreddits ${triggered ? 'met' : 'did not meet'} threshold of ${threshold}`;
return {
result: totalSummary,
data: {
window: typeof this.window === 'number' ? `${activities.length} Items` : activityWindowText(activities),
summary: totalSummary,
subSummary: relevantSubs.join(', '),
subCount: relevantSubs.length,
totalCount: count,
threshold,
testValue
}
};
}
}
@@ -163,17 +177,20 @@ export class RecentActivityRule extends Rule {
* */
export interface SubThreshold extends SubredditCriteria {
/**
* The number of activities in each subreddit from the list that will trigger this rule
* @minimum 1
* @examples [1]
* A string containing a comparison operator and a value to compare recent activities against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 3` => greater than 3 activities found in the listed subreddits
* * EX `<= 75%` => number of Activities in the subreddits listed are equal to or less than 75% of all Activities
*
* **Note:** If you use percentage comparison here as well as `useSubmissionAsReference` then "all Activities" is only pertains to Activities that had the Link of the Submission, rather than all Activities from this window.
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* @default ">= 1"
* @examples [">= 1"]
* */
count?: number,
/**
* The total number of activities across all listed subreddits that will trigger this rule
* @minimum 1
* @examples [1]
* */
totalCount?: number
threshold?: string
}
interface RecentActivityConfig extends ActivityWindow, ReferenceSubmission {

View File

@@ -2,7 +2,7 @@ import {RecentActivityRule, RecentActivityRuleJSONConfig} from "./RecentActivity
import RepeatActivityRule, {RepeatActivityJSONConfig} from "./SubmissionRule/RepeatActivityRule";
import {Rule, RuleJSONConfig} from "./index";
import AuthorRule, {AuthorRuleJSONConfig} from "./AuthorRule";
import {AttributionJSONConfig, AttributionRule} from "./SubmissionRule/AttributionRule";
import {AttributionJSONConfig, AttributionRule} from "./AttributionRule";
import {Logger} from "winston";
import HistoryRule, {HistoryJSONConfig} from "./HistoryRule";

View File

@@ -1,4 +1,4 @@
import {IRule, Triggerable, Rule, RuleJSONConfig, RuleResult} from "./index";
import {IRule, Triggerable, Rule, RuleJSONConfig, RuleResult, RuleSetResult} from "./index";
import {Comment, Submission} from "snoowrap";
import {ruleFactory} from "./RuleFactory";
import {createAjvFactory, mergeArr} from "../util";
@@ -8,7 +8,7 @@ import * as RuleSchema from '../Schema/Rule.json';
import Ajv from 'ajv';
import {RuleJson, RuleObjectJson} from "../Common/types";
export class RuleSet implements IRuleSet, Triggerable {
export class RuleSet implements IRuleSet {
rules: Rule[] = [];
condition: JoinOperands;
logger: Logger;
@@ -32,12 +32,12 @@ export class RuleSet implements IRuleSet, Triggerable {
}
}
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[boolean, RuleResult[]]> {
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[boolean, RuleSetResult]> {
let results: RuleResult[] = [];
let runOne = false;
for (const r of this.rules) {
const combinedResults = [...existingResults, ...results];
const [passed, [result]] = await r.run(item, combinedResults);
const [passed, result] = await r.run(item, combinedResults);
//results = results.concat(determineNewResults(combinedResults, result));
results.push(result);
// skip rule if author check failed
@@ -47,22 +47,30 @@ export class RuleSet implements IRuleSet, Triggerable {
runOne = true;
if (passed) {
if (this.condition === 'OR') {
return [true, results];
return [true, this.generateResultSet(true, results)];
}
} else if (this.condition === 'AND') {
return [false, results];
return [false, this.generateResultSet(false, results)];
}
}
// if no rules were run it's the same as if nothing was triggered
if (!runOne) {
return [false, results];
return [false, this.generateResultSet(false, results)];
}
if(this.condition === 'OR') {
// if OR and did not return already then none passed
return [false, results];
return [false, this.generateResultSet(false, results)];
}
// otherwise AND and did not return already so all passed
return [true, results];
return [true, this.generateResultSet(true, results)];
}
generateResultSet(triggered: boolean, results: RuleResult[]): RuleSetResult {
return {
results,
triggered,
condition: this.condition
};
}
}

View File

@@ -1,344 +0,0 @@
import {SubmissionRule, SubmissionRuleJSONConfig} from "./index";
import {ActivityWindowType, ReferenceSubmission} from "../../Common/interfaces";
import {RuleOptions, RuleResult} from "../index";
import Submission from "snoowrap/dist/objects/Submission";
import {getAttributionIdentifier} 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 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 this.resources.getAuthorSubmissions(item.author, {window: window}) : await this.resources.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}`;
this.logger.verbose(result);
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

@@ -1,7 +1,12 @@
import {SubmissionRule, SubmissionRuleJSONConfig} from "./index";
import {RuleOptions, RuleResult} from "../index";
import {Comment} from "snoowrap";
import {activityWindowText, parseUsableLinkIdentifier as linkParser} from "../../util";
import {
activityWindowText,
comparisonTextOp, FAIL,
parseGenericValueComparison, parseSubredditName,
parseUsableLinkIdentifier as linkParser, PASS
} from "../../util";
import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../../Common/interfaces";
import Submission from "snoowrap/dist/objects/Submission";
import dayjs from "dayjs";
@@ -33,36 +38,39 @@ const getActivityIdentifier = (activity: (Submission | Comment), length = 200) =
}
export class RepeatActivityRule extends SubmissionRule {
threshold: number;
threshold: string;
window: ActivityWindowType;
gapAllowance?: number;
useSubmissionAsReference: boolean;
lookAt: 'submissions' | 'all';
include: string[];
exclude: string[];
keepRemoved: boolean;
constructor(options: RepeatActivityOptions) {
super(options);
const {
threshold = 5,
window = 15,
threshold = '> 5',
window = 100,
gapAllowance,
useSubmissionAsReference = true,
lookAt = 'all',
include = [],
exclude = []
exclude = [],
keepRemoved = false,
} = options;
this.keepRemoved = keepRemoved;
this.threshold = threshold;
this.window = window;
this.gapAllowance = gapAllowance;
this.useSubmissionAsReference = useSubmissionAsReference;
this.include = include;
this.exclude = exclude;
this.include = include.map(x => parseSubredditName(x).toLowerCase());
this.exclude = exclude.map(x => parseSubredditName(x).toLowerCase());
this.lookAt = lookAt;
}
getKind(): string {
return 'Repeat Activity';
return 'Repeat';
}
getSpecificPremise(): object {
@@ -76,20 +84,27 @@ export class RepeatActivityRule extends SubmissionRule {
}
}
async process(item: Submission): Promise<[boolean, RuleResult[]]> {
async process(item: Submission): Promise<[boolean, RuleResult]> {
const referenceUrl = await item.url;
if (referenceUrl === undefined && this.useSubmissionAsReference) {
this.logger.warn(`Rule not triggered because useSubmissionAsReference=true but submission is not a link`);
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false)]);
}
let filterFunc = (x: any) => true;
if(this.include.length > 0) {
filterFunc = (x: Submission|Comment) => this.include.includes(x.subreddit.display_name.toLowerCase());
} else if(this.exclude.length > 0) {
filterFunc = (x: Submission|Comment) => !this.exclude.includes(x.subreddit.display_name.toLowerCase());
}
let activities: (Submission | Comment)[] = [];
switch (this.lookAt) {
case 'submissions':
activities = await this.resources.getAuthorSubmissions(item.author, {window: this.window});
activities = await this.resources.getAuthorSubmissions(item.author, {window: this.window, keepRemoved: this.keepRemoved});
break;
default:
activities = await this.resources.getAuthorActivities(item.author, {window: this.window});
activities = await this.resources.getAuthorActivities(item.author, {window: this.window, keepRemoved: this.keepRemoved});
break;
}
@@ -97,16 +112,18 @@ export class RepeatActivityRule extends SubmissionRule {
const {openSets = [], allSets = []} = acc;
let identifier = getActivityIdentifier(activity);
const validSub = filterFunc(activity);
let updatedAllSets = [...allSets];
let updatedOpenSets = [];
let updatedOpenSets: RepeatActivityData[] = [];
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) {
if (o.identifier === identifier && validSub) {
updatedOpenSets.push({...o, sets: [...o.sets, activity]});
currIdentifierInOpen = true;
} else if (bufferedActivities.some(x => getActivityIdentifier(x) === identifier)) {
} else if (bufferedActivities.some(x => getActivityIdentifier(x) === identifier) && validSub) {
updatedOpenSets.push(o);
} else {
updatedAllSets.push(o);
@@ -139,36 +156,56 @@ export class RepeatActivityRule extends SubmissionRule {
applicableGroupedActivities.set(getActivityIdentifier(item), referenceSubmissions || [])
}
const {operator, value: thresholdValue} = parseGenericValueComparison(this.threshold);
const greaterThan = operator.includes('>');
let allLessThan = true;
const identifiersSummary: SummaryData[] = [];
for (let [key, value] of applicableGroupedActivities) {
const summaryData = {
const summaryData: SummaryData = {
identifier: key,
totalSets: value.length,
totalTriggeringSets: 0,
largestTrigger: 0,
sets: [],
setsMarkdown: [],
triggeringSets: [],
triggeringSetsMarkdown: [],
};
for (let set of value) {
if (set.length >= this.threshold) {
// @ts-ignore
const test = comparisonTextOp(set.length, operator, thresholdValue);
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()}`);
summaryData.sets.push(set);
summaryData.largestTrigger = Math.max(summaryData.largestTrigger, set.length);
summaryData.setsMarkdown.push(md);
if (test) {
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);
// }
} else if (!greaterThan) {
allLessThan = false;
}
}
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}`;
this.logger.verbose(result);
return Promise.resolve([true, [this.getResult(true, {
const criteriaMet = identifiersSummary.filter(x => x.totalTriggeringSets > 0).length > 0 && (greaterThan || (!greaterThan && allLessThan));
const largestRepeat = identifiersSummary.reduce((acc, summ) => Math.max(summ.largestTrigger, acc), 0);
let result: string;
if (criteriaMet || greaterThan) {
result = `${criteriaMet ? PASS : FAIL} ${identifiersSummary.filter(x => x.totalTriggeringSets > 0).length} of ${identifiersSummary.length} unique items repeated ${this.threshold} times, largest repeat: ${largestRepeat}`;
} else {
result = `${FAIL} Not all of ${identifiersSummary.length} unique items repeated ${this.threshold} times, largest repeat: ${largestRepeat}`
}
this.logger.verbose(result);
if (criteriaMet) {
const triggeringSummaries = identifiersSummary.filter(x => x.totalTriggeringSets > 0);
return Promise.resolve([true, this.getResult(true, {
result,
data: {
window: typeof this.window === 'number' ? `${activities.length} Items` : activityWindowText(activities),
@@ -179,10 +216,10 @@ export class RepeatActivityRule extends SubmissionRule {
url: referenceUrl,
triggeringSummaries,
}
})]]);
})])
}
return Promise.resolve([false, [this.getResult(false)]]);
return Promise.resolve([false, this.getResult(false, {result})]);
}
}
@@ -191,6 +228,8 @@ interface SummaryData {
totalSets: number,
totalTriggeringSets: number,
largestTrigger: number,
sets: (Comment | Submission)[],
setsMarkdown: string[],
triggeringSets: (Comment | Submission)[],
triggeringSetsMarkdown: string[]
}
@@ -198,29 +237,25 @@ interface SummaryData {
interface RepeatActivityConfig extends ActivityWindow, ReferenceSubmission {
/**
* The number of repeat submissions that will trigger the rule
* @default 5
* @default ">= 5"
* */
threshold?: number,
threshold?: string,
/**
* 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.
* Only include Submissions from this list of Subreddits (by name, case-insensitive)
*
* 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"]
* EX `["mealtimevideos","askscience"]`
* @examples ["mealtimevideos","askscience"]
* @minItems 1
* */
include?: string[],
/**
* Do not include Submissions from this list of Subreddits.
* Do not include Submissions from this list of Subreddits (by name, case-insensitive)
*
* 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"]
* EX `["mealtimevideos","askscience"]`
* @examples ["mealtimevideos","askscience"]
* @minItems 1
* */
@@ -235,6 +270,16 @@ interface RepeatActivityConfig extends ActivityWindow, ReferenceSubmission {
* @default all
* */
lookAt?: 'submissions' | 'all',
/**
* Count submissions/comments that have previously been removed.
*
* By default all `Submissions/Commments` that are in a `removed` state will be filtered from `window` (only applies to subreddits you mod).
*
* Setting to `true` could be useful if you also want to also detected removed repeat posts by a user like for example if automoderator removes multiple, consecutive submissions for not following title format correctly.
*
* @default false
* */
keepRemoved?: boolean
}
export interface RepeatActivityOptions extends RepeatActivityConfig, RuleOptions {

View File

@@ -5,6 +5,7 @@ import {findResultByPremise, mergeArr} from "../util";
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
import {ChecksActivityState, TypedActivityStates} from "../Common/interfaces";
import {isItem} from "../Utils/SnoowrapUtils";
import Author, {AuthorOptions} from "../Author/Author";
export interface RuleOptions {
name?: string;
@@ -30,8 +31,18 @@ export interface RuleResult extends ResultContext {
triggered: (boolean | null)
}
export interface RuleSetResult {
results: RuleResult[],
condition: 'OR' | 'AND',
triggered: boolean
}
export const isRuleSetResult = (obj: any): obj is RuleSetResult => {
return typeof obj === 'object' && Array.isArray(obj.results) && obj.condition !== undefined && obj.triggered !== undefined;
}
export interface Triggerable {
run(item: Comment | Submission, existingResults: RuleResult[]): Promise<[(boolean | null), RuleResult[]]>;
run(item: Comment | Submission, existingResults: RuleResult[]): Promise<[(boolean | null), RuleResult?]>;
}
export abstract class Rule implements IRule, Triggerable {
@@ -62,19 +73,19 @@ export abstract class Rule implements IRule, Triggerable {
this.itemIs = itemIs;
this.logger = logger.child({labels: ['Rule',`${this.getRuleUniqueName()}`]}, mergeArr);
this.logger = logger.child({labels: [`Rule ${this.getRuleUniqueName()}`]}, mergeArr);
}
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[(boolean | null), RuleResult[]]> {
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[(boolean | null), RuleResult]> {
const existingResult = findResultByPremise(this.getPremise(), existingResults);
if (existingResult) {
this.logger.debug(`Returning existing result of ${existingResult.triggered ? '✔️' : '❌'}`);
return Promise.resolve([existingResult.triggered, [{...existingResult, name: this.name}]]);
return Promise.resolve([existingResult.triggered, {...existingResult, name: this.name}]);
}
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
if(!itemPass) {
this.logger.verbose(`Item did not pass 'itemIs' test, rule running skipped`);
return Promise.resolve([null, [this.getResult(null, {result: `Item did not pass 'itemIs' test, rule running skipped`})]]);
this.logger.verbose(`(Skipped) Item did not pass 'itemIs' test`);
return Promise.resolve([null, this.getResult(null, {result: `Item did not pass 'itemIs' test`})]);
}
if (this.authorIs.include !== undefined && this.authorIs.include.length > 0) {
for (const auth of this.authorIs.include) {
@@ -82,8 +93,8 @@ export abstract class Rule implements IRule, Triggerable {
return this.process(item);
}
}
this.logger.verbose('Inclusive author criteria not matched, rule running skipped');
return Promise.resolve([null, [this.getResult(null, {result: 'Inclusive author criteria not matched, rule running skipped'})]]);
this.logger.verbose('(Skipped) Inclusive author criteria not matched');
return Promise.resolve([null, this.getResult(null, {result: 'Inclusive author criteria not matched'})]);
}
if (this.authorIs.exclude !== undefined && this.authorIs.exclude.length > 0) {
for (const auth of this.authorIs.exclude) {
@@ -91,13 +102,13 @@ export abstract class Rule implements IRule, Triggerable {
return this.process(item);
}
}
this.logger.verbose('Exclusive author criteria not matched, rule running skipped');
return Promise.resolve([null, [this.getResult(null, {result: 'Exclusive author criteria not matched, rule running skipped'})]]);
this.logger.verbose('(Skipped) Exclusive author criteria not matched');
return Promise.resolve([null, this.getResult(null, {result: 'Exclusive author criteria not matched'})]);
}
return this.process(item);
}
protected abstract process(item: Comment | Submission): Promise<[boolean, RuleResult[]]>;
protected abstract process(item: Comment | Submission): Promise<[boolean, RuleResult]>;
abstract getKind(): string;
@@ -129,103 +140,70 @@ export abstract class Rule implements IRule, Triggerable {
}
}
export class Author implements AuthorCriteria {
name?: string[];
flairCssClass?: string[];
flairText?: string[];
isMod?: boolean;
userNotes?: UserNoteCriteria[];
constructor(options: AuthorCriteria) {
this.name = options.name;
this.flairCssClass = options.flairCssClass;
this.flairText = options.flairText;
this.isMod = options.isMod;
this.userNotes = options.userNotes;
}
}
export interface UserNoteCriteria {
/**
* User Note type key
* User Note type key to search for
* @examples ["spamwarn"]
* */
type: string;
/**
* Number of occurrences of this type. Ignored if `search` is `current`
* @examples [1]
* @default 1
*
* A string containing a comparison operator and/or a value to compare number of occurrences against
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`
*
* @examples [">= 1"]
* @default ">= 1"
* @pattern ^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%?)\s*(?<extra>asc.*|desc.*)*$
* */
count?: number;
count?: string;
/**
* * If `current` then only the most recent note is checked
* * If `consecutive` then `count` number of `type` notes must be found in a row, based on `order` direction
* * If `total` then `count` number of `type` must be found within all notes
* How to test the notes for this Author:
*
* ### current
*
* Only the most recent note is checked for `type`
*
* ### total
*
* The `count` comparison of `type` must be found within all notes
*
* * EX `count: > 3` => Must have more than 3 notes of `type`, total
* * EX `count: <= 25%` => Must have 25% or less of notes of `type`, total
*
* ### consecutive
*
* The `count` **number** of `type` notes must be found in a row.
*
* You may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`
*
* * EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order
* * EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order
* * EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order
*
* @examples ["current"]
* @default current
* */
search?: 'current' | 'consecutive' | 'total'
/**
* Time-based order to search Notes in for `consecutive` search
* @examples ["descending"]
* @default descending
* */
order?: 'ascending' | 'descending'
}
/**
* If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.
* @examples [{"include": [{"flairText": ["Contributor","Veteran"]}, {"isMod": true}]}]
* */
export interface AuthorOptions {
/**
* Will "pass" if any set of AuthorCriteria passes
* */
include?: AuthorCriteria[];
/**
* Only runs if include is not present. Will "pass" if any of set of the AuthorCriteria does not pass
* */
exclude?: AuthorCriteria[];
}
/**
* Criteria with which to test against the author of an Activity. The outcome of the test is based on:
* A duration and how to compare it against a value
*
* 1. All present properties passing and
* 2. If a property is a list then any value from the list matching
* The syntax is `(< OR > OR <= OR >=) <number> <unit>` EX `> 100 days`, `<= 2 months`
*
* @minProperties 1
* @additionalProperties false
* @examples [{"flairText": ["Contributor","Veteran"], "isMod": true, "name": ["FoxxMD", "AnotherUser"] }]
* * EX `> 100 days` => Passes if the date being compared is before 100 days ago
* * EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months
*
* Unit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)
*
* [See] https://regexr.com/609n8 for example
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\s*$
* */
export interface AuthorCriteria {
/**
* A list of reddit usernames (case-insensitive) to match against. Do not include the "u/" prefix
*
* EX to match against /u/FoxxMD and /u/AnotherUser use ["FoxxMD","AnotherUser"]
* @examples ["FoxxMD","AnotherUser"]
* */
name?: string[],
/**
* A list of (user) flair css class values from the subreddit to match against
* @examples ["red"]
* */
flairCssClass?: string[],
/**
* A list of (user) flair text values from the subreddit to match against
* @examples ["Approved"]
* */
flairText?: string[],
/**
* Is the author a moderator?
* */
isMod?: boolean,
/**
* A list of UserNote properties to check against the User Notes attached to this Author in this Subreddit (must have Toolbox enabled and used User Notes at least once)
* */
userNotes?: UserNoteCriteria[]
}
export type DurationComparor = string;
export interface IRule extends ChecksActivityState {
/**

View File

@@ -1,6 +1,274 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"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",
"examples": [
{
"flairText": [
"Contributor",
"Veteran"
],
"isMod": true,
"name": [
"FoxxMD",
"AnotherUser"
]
}
],
"minProperties": 1,
"properties": {
"age": {
"description": "Test the age of the Author's account (when it was created) against this comparison\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>`\n\n* EX `> 100 days` => Passes if Author's account is older than 100 days\n* EX `<= 2 months` => Passes if Author's account is younger than or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<time>\\d+)\\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\\s*$",
"type": "string"
},
"commentKarma": {
"description": "A string containing a comparison operator and a value to compare karma against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 comment karma\n* EX `<= 75%` => comment karma is less than or equal to 75% of **all karma**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flairCssClass": {
"description": "A list of (user) flair css class values from the subreddit to match against",
"examples": [
"red"
],
"items": {
"type": "string"
},
"type": "array"
},
"flairText": {
"description": "A list of (user) flair text values from the subreddit to match against",
"examples": [
"Approved"
],
"items": {
"type": "string"
},
"type": "array"
},
"isMod": {
"description": "Is the author a moderator?",
"type": "boolean"
},
"linkKarma": {
"description": "A string containing a comparison operator and a value to compare link karma against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 link karma\n* EX `<= 75%` => link karma is less than or equal to 75% of **all karma**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
],
"items": {
"type": "string"
},
"type": "array"
},
"totalKarma": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"userNotes": {
"description": "A list of UserNote properties to check against the User Notes attached to this Author in this Subreddit (must have Toolbox enabled and used User Notes at least once)",
"items": {
"$ref": "#/definitions/UserNoteCriteria"
},
"type": "array"
},
"verified": {
"description": "Does Author's account have a verified email?",
"type": "boolean"
}
},
"type": "object"
},
"AuthorOptions": {
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
"examples": [
{
"include": [
{
"flairText": [
"Contributor",
"Veteran"
]
},
{
"isMod": true
}
]
}
],
"properties": {
"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"
}
},
"type": "object"
},
"CommentState": {
"description": "Different attributes a `Comment` can be in. Only include a property if you want to check it.",
"examples": [
{
"op": true,
"removed": false
}
],
"properties": {
"approved": {
"type": "boolean"
},
"deleted": {
"type": "boolean"
},
"distinguished": {
"type": "boolean"
},
"filtered": {
"type": "boolean"
},
"locked": {
"type": "boolean"
},
"op": {
"type": "boolean"
},
"removed": {
"type": "boolean"
},
"spam": {
"type": "boolean"
},
"stickied": {
"type": "boolean"
}
},
"type": "object"
},
"SubmissionState": {
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
"examples": [
{
"over_18": true,
"removed": false
}
],
"properties": {
"approved": {
"type": "boolean"
},
"deleted": {
"type": "boolean"
},
"distinguished": {
"type": "boolean"
},
"filtered": {
"type": "boolean"
},
"is_self": {
"type": "boolean"
},
"locked": {
"type": "boolean"
},
"over_18": {
"description": "NSFW",
"type": "boolean"
},
"pinned": {
"type": "boolean"
},
"removed": {
"type": "boolean"
},
"spam": {
"type": "boolean"
},
"spoiler": {
"type": "boolean"
},
"stickied": {
"type": "boolean"
}
},
"type": "object"
},
"UserNoteCriteria": {
"properties": {
"count": {
"default": ">= 1",
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
"examples": [
">= 1"
],
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"search": {
"default": "current",
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
"enum": [
"consecutive",
"current",
"total"
],
"examples": [
"current"
],
"type": "string"
},
"type": {
"description": "User Note type key to search for",
"examples": [
"spamwarn"
],
"type": "string"
}
},
"required": [
"type"
],
"type": "object"
}
},
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
"description": "If present then these Author criteria are checked before running the Action. If criteria fails then the Action is not run.",
"examples": [
{
"include": [
{
"flairText": [
"Contributor",
"Veteran"
]
},
{
"isMod": true
}
]
}
]
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
@@ -10,6 +278,23 @@
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
"items": {
"$ref": "#/definitions/SubmissionState"
},
"type": "array"
},
{
"items": {
"$ref": "#/definitions/CommentState"
},
"type": "array"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the Action.\n\nIf any set of criteria passes the Action will be run."
},
"kind": {
"description": "The type of action that will be performed",
"enum": [

File diff suppressed because it is too large Load Diff

View File

@@ -23,7 +23,7 @@
"definitions": {
"ActivityWindowCriteria": {
"additionalProperties": false,
"description": "The criteria used to define what range of Activity to retrieve.\n\nMay specify one, or both properties along with the `satisfyOn` property, to affect the retrieval behavior.",
"description": "Multiple properties that may be used to define what range of Activity to retrieve.\n\nMay specify one, or both properties along with the `satisfyOn` property, to affect the retrieval behavior.",
"examples": [
{
"count": 100,
@@ -50,8 +50,9 @@
"type": "string"
}
],
"description": "An [ISO 8601 duration string](https://en.wikipedia.org/wiki/ISO_8601#Durations) or [Day.js duration object](https://day.js.org/docs/en/durations/creating).\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 `PT15M` or `{\"minutes\": 15}`\n* `endTime` = NOW (3:00PM)\n* `startTime` = (NOW - 15 minutes) = 2:45PM\n\nSo look for Activities between 2:45PM and 3:00PM",
"description": "A value that specifies the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating) time unit**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days",
"PT15M",
{
"minutes": 15
@@ -60,7 +61,7 @@
},
"satisfyOn": {
"default": "any",
"description": "Define the condition under which both criteria are considered met\n\n**If `any` then it will retrieve Activities until one of the criteria is met, whichever occurs first**\n\nEX `{count: 100, duration: {days: 90}}`:\n* If 90 days of activities = 40 activities => returns 40 activities\n* If 100 activities is only 20 days => 100 activities\n\n**If `all` then both criteria must be met.**\n\nEffectively, whichever criteria produces the most Activities...\n\nEX `{count: 100, duration: {days: 90}}`:\n* If at 90 days of activities => 40 activities, continue retrieving results until 100 => results in >90 days of activities\n* If at 100 activities => 20 days of activities, continue retrieving results until 90 days => results in >100 activities",
"description": "Define the condition under which both criteria are considered met\n\n**If `any` then it will retrieve Activities until one of the criteria is met, whichever occurs first**\n\nEX `{\"count\": 100, duration: \"90 days\"}`:\n* If 90 days of activities = 40 activities => returns 40 activities\n* If 100 activities is only 20 days => 100 activities\n\n**If `all` then both criteria must be met.**\n\nEffectively, whichever criteria produces the most Activities...\n\nEX `{\"count\": 100, duration: \"90 days\"}`:\n* If at 90 days of activities => 40 activities, continue retrieving results until 100 => results in >90 days of activities\n* If at 100 activities => 20 days of activities, continue retrieving results until 90 days => results in >100 activities",
"enum": [
"all",
"any"
@@ -69,12 +70,112 @@
"any"
],
"type": "string"
},
"subreddits": {
"description": "Filter which subreddits (case-insensitive) Activities are retrieved from.\n\n**Note:** Filtering occurs **before** `duration/count` checks are performed.",
"properties": {
"exclude": {
"description": "Exclude any results from these subreddits\n\n**Note:** `exclude` is ignored if `include` is present",
"examples": [
[
"mealtimevideos",
"askscience"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"include": {
"description": "Include only results from these subreddits",
"examples": [
[
"mealtimevideos",
"askscience"
]
],
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
}
},
"type": "object"
},
"AttributionCriteria": {
"properties": {
"aggregateOn": {
"default": "undefined",
"description": "If `domains` is not specified this list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or reddit image/video (i.redd.it / v.redd.it)\n* If `link` is included then aggregate author's submission history which is external links but not media\n\nIf nothing is specified or list is empty (default) all domains are aggregated",
"examples": [
[
]
],
"items": {
"enum": [
"link",
"media",
"self"
],
"type": "string"
},
"type": "array"
},
"consolidateMediaDomains": {
"default": false,
"description": "Should the criteria consolidate 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 domains will be aggregated at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then media domains will be consolidated at domain level and then aggregated IE youtube.com (5 counts)",
"examples": [
false
],
"type": "boolean"
},
"domains": {
"default": [
[
]
],
"description": "A list of domains whose Activities will be tested against `threshold`.\n\nIf this is present then `aggregateOn` is ignored.\n\nThe values are tested as partial strings so you do not need to include full URLs, just the part that matters.\n\nEX `[\"youtube\"]` will match submissions with the domain `https://youtube.com/c/aChannel`\nEX `[\"youtube.com/c/bChannel\"]` will NOT match submissions with the domain `https://youtube.com/c/aChannel`\n\nIf you wish to aggregate on self-posts for a subreddit use the syntax `self.[subreddit]` EX `self.AskReddit`\n\n**If this Rule is part of a Check for a Submission and you wish to aggregate on the domain of the Submission use the special string `AGG:SELF`**\n\nIf nothing is specified or list is empty (default) aggregate using `aggregateOn`",
"items": {
"type": "string"
},
"type": "array"
},
"domainsCombined": {
"default": false,
"description": "Set to `true` if you wish to combine all of the Activities from `domains` to test against `threshold` instead of testing each `domain` individually",
"examples": [
false
],
"type": "boolean"
},
"exclude": {
"description": "Do not include Activities from this list of Subreddits (by name, case-insensitive)\n\nWill be ignored if `include` is present.\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
],
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
},
"include": {
"description": "Only include Activities from this list of Subreddits (by name, case-insensitive)\n\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
],
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
},
"minActivityCount": {
"default": 5,
"description": "The minimum number of activities that must exist for this criteria to run",
@@ -84,12 +185,10 @@
"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"
]
"default": "> 10%",
"description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"thresholdOn": {
"default": "all",
@@ -114,6 +213,10 @@
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
@@ -124,13 +227,8 @@
"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```",
"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```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\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"
},
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
@@ -166,35 +264,6 @@
],
"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"
},
"itemIs": {
"anyOf": [
{
@@ -219,15 +288,6 @@
],
"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.",
"examples": [
@@ -265,6 +325,16 @@
],
"minProperties": 1,
"properties": {
"age": {
"description": "Test the age of the Author's account (when it was created) against this comparison\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>`\n\n* EX `> 100 days` => Passes if Author's account is older than 100 days\n* EX `<= 2 months` => Passes if Author's account is younger than or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<time>\\d+)\\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\\s*$",
"type": "string"
},
"commentKarma": {
"description": "A string containing a comparison operator and a value to compare karma against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 comment karma\n* EX `<= 75%` => comment karma is less than or equal to 75% of **all karma**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flairCssClass": {
"description": "A list of (user) flair css class values from the subreddit to match against",
"examples": [
@@ -289,6 +359,11 @@
"description": "Is the author a moderator?",
"type": "boolean"
},
"linkKarma": {
"description": "A string containing a comparison operator and a value to compare link karma against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 link karma\n* EX `<= 75%` => link karma is less than or equal to 75% of **all karma**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
@@ -300,12 +375,21 @@
},
"type": "array"
},
"totalKarma": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"userNotes": {
"description": "A list of UserNote properties to check against the User Notes attached to this Author in this Subreddit (must have Toolbox enabled and used User Notes at least once)",
"items": {
"$ref": "#/definitions/UserNoteCriteria"
},
"type": "array"
},
"verified": {
"description": "Does Author's account have a verified email?",
"type": "boolean"
}
},
"type": "object"
@@ -329,7 +413,7 @@
],
"properties": {
"exclude": {
"description": "Only runs if include is not present. Will \"pass\" if any of set of the AuthorCriteria does not pass",
"description": "Only runs if `include` is not present. Will \"pass\" if any of set of the AuthorCriteria **does not** pass",
"items": {
"$ref": "#/definitions/AuthorCriteria"
},
@@ -432,9 +516,15 @@
"approved": {
"type": "boolean"
},
"deleted": {
"type": "boolean"
},
"distinguished": {
"type": "boolean"
},
"filtered": {
"type": "boolean"
},
"locked": {
"type": "boolean"
},
@@ -453,46 +543,6 @@
},
"type": "object"
},
"CommentThresholdCriteria": {
"properties": {
"asOp": {
"description": "If `true` then when threshold...\n\n* is `number` it will be number of comments where author is OP\n* is `percent` it will be **percent of total comments where author is OP**",
"type": "boolean"
},
"condition": {
"enum": [
"<",
"<=",
">",
">="
],
"examples": [
">",
">=",
"<",
"<="
],
"type": "string"
},
"threshold": {
"default": "10%",
"description": "The number or percentage to trigger this criteria at\n\n* If `threshold` is a `number` then it is the absolute number of items to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total this item must reach to trigger",
"examples": [
"10%",
15
],
"type": [
"string",
"number"
]
}
},
"required": [
"condition",
"threshold"
],
"type": "object"
},
"DurationObject": {
"additionalProperties": false,
"description": "A [Day.js duration object](https://day.js.org/docs/en/durations/creating)",
@@ -553,7 +603,9 @@
"description": "If both `submission` and `comment` are defined then criteria will only trigger if BOTH thresholds are met",
"properties": {
"comment": {
"$ref": "#/definitions/CommentThresholdCriteria"
"description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [OP]`\n\n* EX `> 100` => greater than 100 comments\n* EX `<= 75%` => comments are equal to or less than 75% of all Activities\n\nIf your string also contains the text `OP` somewhere **after** `<number>[percent sign]`...:\n\n* EX `> 100 OP` => greater than 100 comments as OP\n* EX `<= 25% as OP` => Comments as OP were less then or equal to 25% of **all Comments**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"minActivityCount": {
"default": 5,
@@ -564,7 +616,9 @@
"type": "string"
},
"submission": {
"$ref": "#/definitions/ThresholdCriteria"
"description": "A string containing a comparison operator and a value to compare submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 submissions\n* EX `<= 75%` => submissions are equal to or less than 75% of all Activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
@@ -581,7 +635,10 @@
]
}
],
"description": "Window defining Activities to consider (both Comment/Submission)"
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
@@ -611,6 +668,14 @@
}
]
},
"condition": {
"description": "* If `OR` then any set of Criteria that pass will trigger the Rule\n* If `AND` then all Criteria sets must pass to trigger the Rule",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"criteria": {
"description": "A list threshold-window values to test activities against.",
"items": {
@@ -619,16 +684,8 @@
"minItems": 1,
"type": "array"
},
"criteriaJoin": {
"description": "* If `OR` then any set of Criteria that pass will trigger the Rule\n* If `AND` then all Criteria sets must pass 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\"]",
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -640,7 +697,7 @@
"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\"]",
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -787,8 +844,10 @@
]
}
],
"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`"
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
@@ -820,7 +879,7 @@
]
},
"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\"]",
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -836,7 +895,7 @@
"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\"]",
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -864,6 +923,11 @@
],
"description": "A list of criteria to test the state of the `Activity` against before running the Rule.\n\nIf any set of criteria passes the Rule will be run. If the criteria fails then the Rule is skipped."
},
"keepRemoved": {
"default": false,
"description": "Count submissions/comments that have previously been removed.\n\nBy default all `Submissions/Commments` that are in a `removed` state will be filtered from `window` (only applies to subreddits you mod).\n\nSetting to `true` could be useful if you also want to also detected removed repeat posts by a user like for example if automoderator removes multiple, consecutive submissions for not following title format correctly.",
"type": "boolean"
},
"kind": {
"description": "The kind of rule to run",
"enum": [
@@ -889,9 +953,9 @@
"type": "string"
},
"threshold": {
"default": 5,
"default": ">= 5",
"description": "The number of repeat submissions that will trigger the rule",
"type": "number"
"type": "string"
},
"useSubmissionAsReference": {
"default": true,
@@ -913,8 +977,10 @@
]
}
],
"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`"
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
@@ -927,16 +993,8 @@
"description": "At least one count property must be present. If both are present then either can trigger the rule",
"minProperties": 1,
"properties": {
"count": {
"description": "The number of activities in each subreddit from the list that will trigger this rule",
"examples": [
1
],
"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\"]",
"description": "A list of Subreddits (by name, case-insensitive) to look for.\n\nEX [\"mealtimevideos\",\"askscience\"]",
"examples": [
[
"mealtimevideos",
@@ -946,16 +1004,17 @@
"items": {
"type": "string"
},
"minItems": 2,
"minItems": 1,
"type": "array"
},
"totalCount": {
"description": "The total number of activities across all listed subreddits that will trigger this rule",
"threshold": {
"default": ">= 1",
"description": "A string containing a comparison operator and a value to compare recent activities against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 3` => greater than 3 activities found in the listed subreddits\n* EX `<= 75%` => number of Activities in the subreddits listed are equal to or less than 75% of all Activities\n\n**Note:** If you use percentage comparison here as well as `useSubmissionAsReference` then \"all Activities\" is only pertains to Activities that had the Link of the Submission, rather than all Activities from this window.",
"examples": [
1
">= 1"
],
"minimum": 1,
"type": "number"
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
}
},
"required": [
@@ -975,9 +1034,15 @@
"approved": {
"type": "boolean"
},
"deleted": {
"type": "boolean"
},
"distinguished": {
"type": "boolean"
},
"filtered": {
"type": "boolean"
},
"is_self": {
"type": "boolean"
},
@@ -1006,67 +1071,20 @@
},
"type": "object"
},
"ThresholdCriteria": {
"properties": {
"condition": {
"enum": [
"<",
"<=",
">",
">="
],
"examples": [
">",
">=",
"<",
"<="
],
"type": "string"
},
"threshold": {
"default": "10%",
"description": "The number or percentage to trigger this criteria at\n\n* If `threshold` is a `number` then it is the absolute number of items to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total this item must reach to trigger",
"examples": [
"10%",
15
],
"type": [
"string",
"number"
]
}
},
"required": [
"condition",
"threshold"
],
"type": "object"
},
"UserNoteCriteria": {
"properties": {
"count": {
"default": 1,
"description": "Number of occurrences of this type. Ignored if `search` is `current`",
"default": ">= 1",
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
"examples": [
1
],
"type": "number"
},
"order": {
"default": "descending",
"description": "Time-based order to search Notes in for `consecutive` search",
"enum": [
"ascending",
"descending"
],
"examples": [
"descending"
">= 1"
],
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"search": {
"default": "current",
"description": "* If `current` then only the most recent note is checked\n* If `consecutive` then `count` number of `type` notes must be found in a row, based on `order` direction\n* If `total` then `count` number of `type` must be found within all notes",
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
"enum": [
"consecutive",
"current",
@@ -1078,7 +1096,7 @@
"type": "string"
},
"type": {
"description": "User Note type key",
"description": "User Note type key to search for",
"examples": [
"spamwarn"
],

View File

@@ -3,7 +3,7 @@
"definitions": {
"ActivityWindowCriteria": {
"additionalProperties": false,
"description": "The criteria used to define what range of Activity to retrieve.\n\nMay specify one, or both properties along with the `satisfyOn` property, to affect the retrieval behavior.",
"description": "Multiple properties that may be used to define what range of Activity to retrieve.\n\nMay specify one, or both properties along with the `satisfyOn` property, to affect the retrieval behavior.",
"examples": [
{
"count": 100,
@@ -30,8 +30,9 @@
"type": "string"
}
],
"description": "An [ISO 8601 duration string](https://en.wikipedia.org/wiki/ISO_8601#Durations) or [Day.js duration object](https://day.js.org/docs/en/durations/creating).\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 `PT15M` or `{\"minutes\": 15}`\n* `endTime` = NOW (3:00PM)\n* `startTime` = (NOW - 15 minutes) = 2:45PM\n\nSo look for Activities between 2:45PM and 3:00PM",
"description": "A value that specifies the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating) time unit**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days",
"PT15M",
{
"minutes": 15
@@ -40,7 +41,7 @@
},
"satisfyOn": {
"default": "any",
"description": "Define the condition under which both criteria are considered met\n\n**If `any` then it will retrieve Activities until one of the criteria is met, whichever occurs first**\n\nEX `{count: 100, duration: {days: 90}}`:\n* If 90 days of activities = 40 activities => returns 40 activities\n* If 100 activities is only 20 days => 100 activities\n\n**If `all` then both criteria must be met.**\n\nEffectively, whichever criteria produces the most Activities...\n\nEX `{count: 100, duration: {days: 90}}`:\n* If at 90 days of activities => 40 activities, continue retrieving results until 100 => results in >90 days of activities\n* If at 100 activities => 20 days of activities, continue retrieving results until 90 days => results in >100 activities",
"description": "Define the condition under which both criteria are considered met\n\n**If `any` then it will retrieve Activities until one of the criteria is met, whichever occurs first**\n\nEX `{\"count\": 100, duration: \"90 days\"}`:\n* If 90 days of activities = 40 activities => returns 40 activities\n* If 100 activities is only 20 days => 100 activities\n\n**If `all` then both criteria must be met.**\n\nEffectively, whichever criteria produces the most Activities...\n\nEX `{\"count\": 100, duration: \"90 days\"}`:\n* If at 90 days of activities => 40 activities, continue retrieving results until 100 => results in >90 days of activities\n* If at 100 activities => 20 days of activities, continue retrieving results until 90 days => results in >100 activities",
"enum": [
"all",
"any"
@@ -49,12 +50,112 @@
"any"
],
"type": "string"
},
"subreddits": {
"description": "Filter which subreddits (case-insensitive) Activities are retrieved from.\n\n**Note:** Filtering occurs **before** `duration/count` checks are performed.",
"properties": {
"exclude": {
"description": "Exclude any results from these subreddits\n\n**Note:** `exclude` is ignored if `include` is present",
"examples": [
[
"mealtimevideos",
"askscience"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"include": {
"description": "Include only results from these subreddits",
"examples": [
[
"mealtimevideos",
"askscience"
]
],
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
}
},
"type": "object"
},
"AttributionCriteria": {
"properties": {
"aggregateOn": {
"default": "undefined",
"description": "If `domains` is not specified this list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or reddit image/video (i.redd.it / v.redd.it)\n* If `link` is included then aggregate author's submission history which is external links but not media\n\nIf nothing is specified or list is empty (default) all domains are aggregated",
"examples": [
[
]
],
"items": {
"enum": [
"link",
"media",
"self"
],
"type": "string"
},
"type": "array"
},
"consolidateMediaDomains": {
"default": false,
"description": "Should the criteria consolidate 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 domains will be aggregated at the channel level IE Youtube Channel A (2 counts), Youtube Channel B (3 counts)\n* If `true` then then media domains will be consolidated at domain level and then aggregated IE youtube.com (5 counts)",
"examples": [
false
],
"type": "boolean"
},
"domains": {
"default": [
[
]
],
"description": "A list of domains whose Activities will be tested against `threshold`.\n\nIf this is present then `aggregateOn` is ignored.\n\nThe values are tested as partial strings so you do not need to include full URLs, just the part that matters.\n\nEX `[\"youtube\"]` will match submissions with the domain `https://youtube.com/c/aChannel`\nEX `[\"youtube.com/c/bChannel\"]` will NOT match submissions with the domain `https://youtube.com/c/aChannel`\n\nIf you wish to aggregate on self-posts for a subreddit use the syntax `self.[subreddit]` EX `self.AskReddit`\n\n**If this Rule is part of a Check for a Submission and you wish to aggregate on the domain of the Submission use the special string `AGG:SELF`**\n\nIf nothing is specified or list is empty (default) aggregate using `aggregateOn`",
"items": {
"type": "string"
},
"type": "array"
},
"domainsCombined": {
"default": false,
"description": "Set to `true` if you wish to combine all of the Activities from `domains` to test against `threshold` instead of testing each `domain` individually",
"examples": [
false
],
"type": "boolean"
},
"exclude": {
"description": "Do not include Activities from this list of Subreddits (by name, case-insensitive)\n\nWill be ignored if `include` is present.\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
],
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
},
"include": {
"description": "Only include Activities from this list of Subreddits (by name, case-insensitive)\n\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
],
"items": {
"type": "string"
},
"minItems": 1,
"type": "array"
},
"minActivityCount": {
"default": 5,
"description": "The minimum number of activities that must exist for this criteria to run",
@@ -64,12 +165,10 @@
"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"
]
"default": "> 10%",
"description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"thresholdOn": {
"default": "all",
@@ -94,6 +193,10 @@
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
@@ -104,13 +207,8 @@
"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```",
"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```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\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"
},
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
"description": "If present then these Author criteria are checked before running the rule. If criteria fails then the rule is skipped.",
@@ -146,35 +244,6 @@
],
"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"
},
"itemIs": {
"anyOf": [
{
@@ -199,15 +268,6 @@
],
"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.",
"examples": [
@@ -245,6 +305,16 @@
],
"minProperties": 1,
"properties": {
"age": {
"description": "Test the age of the Author's account (when it was created) against this comparison\n\nThe syntax is `(< OR > OR <= OR >=) <number> <unit>`\n\n* EX `> 100 days` => Passes if Author's account is older than 100 days\n* EX `<= 2 months` => Passes if Author's account is younger than or equal to 2 months\n\nUnit must be one of [DayJS Duration units](https://day.js.org/docs/en/durations/creating)\n\n[See] https://regexr.com/609n8 for example",
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<time>\\d+)\\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\\s*$",
"type": "string"
},
"commentKarma": {
"description": "A string containing a comparison operator and a value to compare karma against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 comment karma\n* EX `<= 75%` => comment karma is less than or equal to 75% of **all karma**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flairCssClass": {
"description": "A list of (user) flair css class values from the subreddit to match against",
"examples": [
@@ -269,6 +339,11 @@
"description": "Is the author a moderator?",
"type": "boolean"
},
"linkKarma": {
"description": "A string containing a comparison operator and a value to compare link karma against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 link karma\n* EX `<= 75%` => link karma is less than or equal to 75% of **all karma**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
@@ -280,12 +355,21 @@
},
"type": "array"
},
"totalKarma": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"userNotes": {
"description": "A list of UserNote properties to check against the User Notes attached to this Author in this Subreddit (must have Toolbox enabled and used User Notes at least once)",
"items": {
"$ref": "#/definitions/UserNoteCriteria"
},
"type": "array"
},
"verified": {
"description": "Does Author's account have a verified email?",
"type": "boolean"
}
},
"type": "object"
@@ -309,7 +393,7 @@
],
"properties": {
"exclude": {
"description": "Only runs if include is not present. Will \"pass\" if any of set of the AuthorCriteria does not pass",
"description": "Only runs if `include` is not present. Will \"pass\" if any of set of the AuthorCriteria **does not** pass",
"items": {
"$ref": "#/definitions/AuthorCriteria"
},
@@ -412,9 +496,15 @@
"approved": {
"type": "boolean"
},
"deleted": {
"type": "boolean"
},
"distinguished": {
"type": "boolean"
},
"filtered": {
"type": "boolean"
},
"locked": {
"type": "boolean"
},
@@ -433,46 +523,6 @@
},
"type": "object"
},
"CommentThresholdCriteria": {
"properties": {
"asOp": {
"description": "If `true` then when threshold...\n\n* is `number` it will be number of comments where author is OP\n* is `percent` it will be **percent of total comments where author is OP**",
"type": "boolean"
},
"condition": {
"enum": [
"<",
"<=",
">",
">="
],
"examples": [
">",
">=",
"<",
"<="
],
"type": "string"
},
"threshold": {
"default": "10%",
"description": "The number or percentage to trigger this criteria at\n\n* If `threshold` is a `number` then it is the absolute number of items to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total this item must reach to trigger",
"examples": [
"10%",
15
],
"type": [
"string",
"number"
]
}
},
"required": [
"condition",
"threshold"
],
"type": "object"
},
"DurationObject": {
"additionalProperties": false,
"description": "A [Day.js duration object](https://day.js.org/docs/en/durations/creating)",
@@ -533,7 +583,9 @@
"description": "If both `submission` and `comment` are defined then criteria will only trigger if BOTH thresholds are met",
"properties": {
"comment": {
"$ref": "#/definitions/CommentThresholdCriteria"
"description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [OP]`\n\n* EX `> 100` => greater than 100 comments\n* EX `<= 75%` => comments are equal to or less than 75% of all Activities\n\nIf your string also contains the text `OP` somewhere **after** `<number>[percent sign]`...:\n\n* EX `> 100 OP` => greater than 100 comments as OP\n* EX `<= 25% as OP` => Comments as OP were less then or equal to 25% of **all Comments**",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"minActivityCount": {
"default": 5,
@@ -544,7 +596,9 @@
"type": "string"
},
"submission": {
"$ref": "#/definitions/ThresholdCriteria"
"description": "A string containing a comparison operator and a value to compare submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 submissions\n* EX `<= 75%` => submissions are equal to or less than 75% of all Activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
@@ -561,7 +615,10 @@
]
}
],
"description": "Window defining Activities to consider (both Comment/Submission)"
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
@@ -591,6 +648,14 @@
}
]
},
"condition": {
"description": "* If `OR` then any set of Criteria that pass will trigger the Rule\n* If `AND` then all Criteria sets must pass to trigger the Rule",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"criteria": {
"description": "A list threshold-window values to test activities against.",
"items": {
@@ -599,16 +664,8 @@
"minItems": 1,
"type": "array"
},
"criteriaJoin": {
"description": "* If `OR` then any set of Criteria that pass will trigger the Rule\n* If `AND` then all Criteria sets must pass 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\"]",
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -620,7 +677,7 @@
"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\"]",
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -767,8 +824,10 @@
]
}
],
"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`"
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
@@ -800,7 +859,7 @@
]
},
"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\"]",
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -816,7 +875,7 @@
"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\"]",
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
"examples": [
"mealtimevideos",
"askscience"
@@ -844,6 +903,11 @@
],
"description": "A list of criteria to test the state of the `Activity` against before running the Rule.\n\nIf any set of criteria passes the Rule will be run. If the criteria fails then the Rule is skipped."
},
"keepRemoved": {
"default": false,
"description": "Count submissions/comments that have previously been removed.\n\nBy default all `Submissions/Commments` that are in a `removed` state will be filtered from `window` (only applies to subreddits you mod).\n\nSetting to `true` could be useful if you also want to also detected removed repeat posts by a user like for example if automoderator removes multiple, consecutive submissions for not following title format correctly.",
"type": "boolean"
},
"kind": {
"description": "The kind of rule to run",
"enum": [
@@ -869,9 +933,9 @@
"type": "string"
},
"threshold": {
"default": 5,
"default": ">= 5",
"description": "The number of repeat submissions that will trigger the rule",
"type": "number"
"type": "string"
},
"useSubmissionAsReference": {
"default": true,
@@ -893,8 +957,10 @@
]
}
],
"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`"
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
@@ -907,16 +973,8 @@
"description": "At least one count property must be present. If both are present then either can trigger the rule",
"minProperties": 1,
"properties": {
"count": {
"description": "The number of activities in each subreddit from the list that will trigger this rule",
"examples": [
1
],
"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\"]",
"description": "A list of Subreddits (by name, case-insensitive) to look for.\n\nEX [\"mealtimevideos\",\"askscience\"]",
"examples": [
[
"mealtimevideos",
@@ -926,16 +984,17 @@
"items": {
"type": "string"
},
"minItems": 2,
"minItems": 1,
"type": "array"
},
"totalCount": {
"description": "The total number of activities across all listed subreddits that will trigger this rule",
"threshold": {
"default": ">= 1",
"description": "A string containing a comparison operator and a value to compare recent activities against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 3` => greater than 3 activities found in the listed subreddits\n* EX `<= 75%` => number of Activities in the subreddits listed are equal to or less than 75% of all Activities\n\n**Note:** If you use percentage comparison here as well as `useSubmissionAsReference` then \"all Activities\" is only pertains to Activities that had the Link of the Submission, rather than all Activities from this window.",
"examples": [
1
">= 1"
],
"minimum": 1,
"type": "number"
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
}
},
"required": [
@@ -955,9 +1014,15 @@
"approved": {
"type": "boolean"
},
"deleted": {
"type": "boolean"
},
"distinguished": {
"type": "boolean"
},
"filtered": {
"type": "boolean"
},
"is_self": {
"type": "boolean"
},
@@ -986,67 +1051,20 @@
},
"type": "object"
},
"ThresholdCriteria": {
"properties": {
"condition": {
"enum": [
"<",
"<=",
">",
">="
],
"examples": [
">",
">=",
"<",
"<="
],
"type": "string"
},
"threshold": {
"default": "10%",
"description": "The number or percentage to trigger this criteria at\n\n* If `threshold` is a `number` then it is the absolute number of items to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total this item must reach to trigger",
"examples": [
"10%",
15
],
"type": [
"string",
"number"
]
}
},
"required": [
"condition",
"threshold"
],
"type": "object"
},
"UserNoteCriteria": {
"properties": {
"count": {
"default": 1,
"description": "Number of occurrences of this type. Ignored if `search` is `current`",
"default": ">= 1",
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
"examples": [
1
],
"type": "number"
},
"order": {
"default": "descending",
"description": "Time-based order to search Notes in for `consecutive` search",
"enum": [
"ascending",
"descending"
],
"examples": [
"descending"
">= 1"
],
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"search": {
"default": "current",
"description": "* If `current` then only the most recent note is checked\n* If `consecutive` then `count` number of `type` notes must be found in a row, based on `order` direction\n* If `total` then `count` number of `type` must be found within all notes",
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
"enum": [
"consecutive",
"current",
@@ -1058,7 +1076,7 @@
"type": "string"
},
"type": {
"description": "User Note type key",
"description": "User Note type key to search for",
"examples": [
"spamwarn"
],

View File

@@ -4,33 +4,44 @@ import {SubmissionCheck} from "../Check/SubmissionCheck";
import {CommentCheck} from "../Check/CommentCheck";
import {
determineNewResults,
mergeArr,
mergeArr, parseFromJsonOrYamlToObject, sleep,
} from "../util";
import {CommentStream, SubmissionStream} from "snoostorm";
import {CommentStream, SubmissionStream, Poll, ModQueueStream} from "snoostorm";
import pEvent from "p-event";
import {RuleResult} from "../Rule";
import {ConfigBuilder} from "../ConfigBuilder";
import {ManagerOptions, PollingOptions} from "../Common/interfaces";
import {ConfigBuilder, buildPollingOptions} from "../ConfigBuilder";
import {ManagerOptions, PollingOptionsStrong} from "../Common/interfaces";
import Submission from "snoowrap/dist/objects/Submission";
import {itemContentPeek} from "../Utils/SnoowrapUtils";
import dayjs from "dayjs";
import LoggedError from "../Utils/LoggedError";
import ResourceManager, {SubredditResources} from "./SubredditResources";
import ResourceManager, {
SubredditResourceOptions,
SubredditResources,
SubredditResourceSetOptions
} from "./SubredditResources";
import {UnmoderatedStream} from "./Streams";
import EventEmitter from "events";
import ConfigParseError from "../Utils/ConfigParseError";
import dayjs, { Dayjs as DayjsObj } from "dayjs";
export class Manager {
subreddit: Subreddit;
client: Snoowrap;
logger: Logger;
pollOptions: PollingOptions;
submissionChecks: SubmissionCheck[];
commentChecks: CommentCheck[];
resources: SubredditResources;
pollOptions!: PollingOptionsStrong[];
submissionChecks!: SubmissionCheck[];
commentChecks!: CommentCheck[];
resources!: SubredditResources;
wikiLocation: string = 'botconfig/contextbot';
lastWikiRevision?: DayjsObj
lastWikiCheck: DayjsObj = dayjs();
wikiUpdateRunning: boolean = false;
subListedOnce = false;
streamSub?: SubmissionStream;
commentsListedOnce = false;
streamComments?: CommentStream;
streamListedOnce: string[] = [];
streams: Poll<Snoowrap.Submission | Snoowrap.Comment>[] = [];
dryRun?: boolean;
globalDryRun?: boolean;
emitter: EventEmitter = new EventEmitter();
displayLabel: string;
currentLabels?: string[];
@@ -53,23 +64,54 @@ export class Manager {
return getLabels()
}
}, mergeArr);
const configBuilder = new ConfigBuilder({logger: this.logger});
const validJson = configBuilder.validateJson(sourceData);
const {checks, ...configManagerOpts} = validJson;
const {polling = {}, caching, dryRun} = configManagerOpts || {};
this.pollOptions = {...polling, ...opts.polling};
this.subreddit = sub;
this.client = client;
this.dryRun = opts.dryRun || dryRun;
this.parseConfigurationFromObject(sourceData);
}
const cacheConfig = caching === false ? {enabled: false, logger: this.logger, subreddit: sub} : {
...caching,
enabled: true,
logger: this.logger,
subreddit: sub,
protected parseConfigurationFromObject(configObj: object) {
const configBuilder = new ConfigBuilder({logger: this.logger});
const validJson = configBuilder.validateJson(configObj);
const {checks, ...configManagerOpts} = validJson;
const {
polling = [{pollOn: 'unmoderated', limit: 25, interval: 20000}],
caching,
dryRun,
footer,
nickname
} = configManagerOpts || {};
this.pollOptions = buildPollingOptions(polling);
this.dryRun = this.globalDryRun || dryRun;
if(nickname !== undefined) {
this.displayLabel = nickname;
this.currentLabels = [this.displayLabel];
}
if(footer !== undefined) {
this.resources.footer = footer;
}
let resourceConfig: SubredditResourceSetOptions = {
footer,
enabled: true
};
this.resources = ResourceManager.set(sub.display_name, cacheConfig);
if(caching === false) {
resourceConfig.enabled = false;
} else {
resourceConfig = {...resourceConfig, ...caching};
}
if(this.resources === undefined) {
this.resources = ResourceManager.set(this.subreddit.display_name, {
...resourceConfig,
logger: this.logger,
subreddit: this.subreddit
});
}
this.resources.setOptions(resourceConfig);
this.logger.info('Subreddit-specific options updated');
this.logger.info('Building Checks...');
const commentChecks: Array<CommentCheck> = [];
const subChecks: Array<SubmissionCheck> = [];
@@ -79,7 +121,7 @@ export class Manager {
...jCheck,
dryRun: this.dryRun || jCheck.dryRun,
logger: this.logger,
subredditName: sub.display_name
subredditName: this.subreddit.display_name
};
if (jCheck.kind === 'comment') {
commentChecks.push(new CommentCheck(checkConfig));
@@ -98,16 +140,85 @@ export class Manager {
}
}
async parseConfiguration(force: boolean = false) {
this.wikiUpdateRunning = true;
this.lastWikiCheck = dayjs();
let sourceData: string;
try {
// @ts-ignore
const wiki = await this.subreddit.getWikiPage(this.wikiLocation).fetch();
const revisionDate = dayjs.unix(wiki.revision_date);
if (!force && (this.lastWikiRevision !== undefined && this.lastWikiRevision.isSame(revisionDate))) {
// nothing to do, we already have this revision
this.wikiUpdateRunning = false;
this.logger.verbose('Config is up to date');
return;
}
if (this.lastWikiRevision !== undefined) {
this.logger.info(`Updating config due to stale wiki page (${dayjs.duration(dayjs().diff(revisionDate)).humanize()} old)`)
}
this.lastWikiRevision = revisionDate;
sourceData = await wiki.content_md;
} catch (err) {
const msg = `Could not read wiki configuration. Please ensure the page https://reddit.com${this.subreddit.url}wiki/${this.wikiLocation} exists and is readable -- error: ${err.message}`;
this.logger.error(msg);
this.wikiUpdateRunning = false;
throw new ConfigParseError(msg);
}
if (sourceData === '') {
this.logger.error(`Wiki page contents was empty`);
this.wikiUpdateRunning = false;
throw new ConfigParseError('Wiki page contents was empty');
}
const [configObj, jsonErr, yamlErr] = parseFromJsonOrYamlToObject(sourceData);
if (configObj === undefined) {
this.logger.error(`Could not parse wiki page contents as JSON or YAML:`);
this.logger.error(jsonErr);
this.logger.error(yamlErr);
this.wikiUpdateRunning = false;
throw new ConfigParseError('Could not parse wiki page contents as JSON or YAML:')
}
this.wikiUpdateRunning = false;
this.parseConfigurationFromObject(configObj);
this.logger.info('Checks updated');
}
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 itemIdentifier = `${checkType === 'Submission' ? 'SUB' : 'COM'} ${itemId}`;
this.currentLabels = [this.displayLabel, itemIdentifier];
const [peek, _] = await itemContentPeek(item);
this.logger.info(`<EVENT> ${peek}`);
while(this.wikiUpdateRunning) {
// sleep for a few seconds while we get new config zzzz
this.logger.verbose('A wiki config update is running, delaying checks by 3 seconds');
await sleep(3000);
}
if(this.lastWikiCheck.diff(dayjs(), 's') > 60) {
// last checked more than 60 seconds ago for config, try and update
await this.parseConfiguration();
}
const startingApiLimit = this.client.ratelimitRemaining;
if(item instanceof Submission) {
if(await item.removed_by_category === 'deleted') {
this.logger.warn('Submission was deleted, cannot process.');
return;
}
} else if(item.author.name === '[deleted]') {
this.logger.warn('Comment was deleted, cannot process.');
return;
}
let checksRun = 0;
let actionsRun = 0;
let totalRulesRun = 0;
@@ -129,11 +240,13 @@ export class Manager {
allRuleResults = allRuleResults.concat(determineNewResults(allRuleResults, checkResults));
triggered = checkTriggered;
} catch (e) {
this.logger.warn(`[Check ${check.name}] Failed with error: ${e.message}`, e);
if(e.logged !== true) {
this.logger.warn(`Running rules for Check ${check.name} failed due to uncaught exception`, e);
}
}
if (triggered) {
const runActions = await check.runActions(item, currentResults);
const runActions = await check.runActions(item, currentResults.filter(x => x.triggered));
actionsRun = runActions.length;
break;
}
@@ -144,7 +257,7 @@ export class Manager {
}
} catch (err) {
if (!(err instanceof LoggedError)) {
if (!(err instanceof LoggedError) && err.logged !== true) {
this.logger.error('An unhandled error occurred while running checks', err);
}
} finally {
@@ -155,71 +268,90 @@ export class Manager {
}
async handle(): Promise<void> {
if(this.submissionChecks.length === 0 && this.commentChecks.length === 0) {
this.logger.warn('No submission or comment checks to run! Bot will not run.');
return;
}
try {
if (this.submissionChecks.length > 0) {
const {
submissions: {
limit = 10,
interval = 10000,
} = {}
} = this.pollOptions
this.streamSub = new SubmissionStream(this.client, {
subreddit: this.subreddit.display_name,
limit,
pollTime: interval,
});
for(const pollOpt of this.pollOptions) {
let stream: Poll<Snoowrap.Submission | Snoowrap.Comment>;
this.streamSub.once('listing', async (listing) => {
this.subListedOnce = true;
switch(pollOpt.pollOn) {
case 'unmoderated':
stream = new UnmoderatedStream(this.client, {
subreddit: this.subreddit.display_name,
limit: pollOpt.limit,
pollTime: pollOpt.interval,
});
break;
case 'modqueue':
stream = new ModQueueStream(this.client, {
subreddit: this.subreddit.display_name,
limit: pollOpt.limit,
pollTime: pollOpt.interval,
});
break;
case 'newSub':
stream = new SubmissionStream(this.client, {
subreddit: this.subreddit.display_name,
limit: pollOpt.limit,
pollTime: pollOpt.interval,
});
break;
case 'newComm':
stream = new CommentStream(this.client, {
subreddit: this.subreddit.display_name,
limit: pollOpt.limit,
pollTime: pollOpt.interval,
});
break;
}
stream.once('listing', async (listing) => {
// warning if poll event could potentially miss activities
if(this.commentChecks.length === 0 && ['unmoderated','modqueue','newComm'].some(x => x === pollOpt.pollOn)) {
this.logger.warn(`Polling '${pollOpt.pollOn}' may return Comments but no comments checks were configured.`);
}
if(this.submissionChecks.length === 0 && ['unmoderated','modqueue','newSub'].some(x => x === pollOpt.pollOn)) {
this.logger.warn(`Polling '${pollOpt.pollOn}' may return Submissions but no submission checks were configured.`);
}
this.streamListedOnce.push(pollOpt.pollOn);
});
this.streamSub.on('item', async (item) => {
if (!this.subListedOnce) {
stream.on('item', async (item) => {
if (!this.streamListedOnce.includes(pollOpt.pollOn)) {
return;
}
await this.runChecks('Submission', item)
});
//this.streamSub.on('listing', (_) => this.logger.debug('Polled Submissions'));
}
if (this.commentChecks.length > 0) {
const {
comments: {
limit = 10,
interval = 10000,
} = {}
} = this.pollOptions
this.streamComments = new CommentStream(this.client, {
subreddit: this.subreddit.display_name,
limit,
pollTime: interval,
});
this.streamComments.once('listing', () => this.commentsListedOnce = true);
this.streamComments.on('item', async (item) => {
if (!this.commentsListedOnce) {
return;
if(item instanceof Submission) {
if(this.submissionChecks.length > 0) {
await this.runChecks('Submission', item);
}
} else if(this.commentChecks.length > 0) {
await this.runChecks('Comment', item)
}
await this.runChecks('Comment', item)
});
//this.streamComments.on('listing', (_) => this.logger.debug('Polled Comments'));
this.streams.push(stream);
}
this.running = true;
this.logger.info('Bot Running');
if (this.streamSub !== undefined) {
this.logger.info('Bot Running');
await pEvent(this.streamSub, 'end');
} else if (this.streamComments !== undefined) {
this.logger.info('Bot Running');
await pEvent(this.streamComments, 'end');
} else {
this.logger.warn('No submission or comment checks to run! Bot will not run.');
return;
}
await pEvent(this.emitter, 'end');
} catch (err) {
this.logger.error('Encountered unhandled error, manager is bailing out');
this.logger.error(err);
} finally {
this.stop();
}
}
stop() {
if(this.running) {
for(const s of this.streams) {
s.end();
}
this.emitter.emit('end');
this.running = false;
this.logger.info('Bot Stopped');
}

16
src/Subreddit/Streams.ts Normal file
View File

@@ -0,0 +1,16 @@
import { Poll, SnooStormOptions } from "snoostorm"
import Snoowrap from "snoowrap";
export class UnmoderatedStream extends Poll<
Snoowrap.Submission | Snoowrap.Comment
> {
constructor(
client: Snoowrap,
options: SnooStormOptions & { subreddit: string }) {
super({
frequency: options.pollTime || 20000,
get: async () => client.getSubreddit(options.subreddit).getUnmoderated(options),
identifier: "id",
});
}
}

View File

@@ -1,47 +1,77 @@
import {RedditUser, Comment, Submission} from "snoowrap";
import Snoowrap, {RedditUser, Comment, Submission} from "snoowrap";
import cache from 'memory-cache';
import objectHash from 'object-hash';
import {
AuthorActivitiesOptions,
AuthorTypedActivitiesOptions,
AuthorTypedActivitiesOptions, BOT_LINK,
getAuthorActivities,
testAuthorCriteria
} from "../Utils/SnoowrapUtils";
import Subreddit from 'snoowrap/dist/objects/Subreddit';
import winston, {Logger} from "winston";
import {mergeArr} from "../util";
import fetch from 'node-fetch';
import {mergeArr, parseExternalUrl, parseWikiContext} from "../util";
import LoggedError from "../Utils/LoggedError";
import {SubredditCacheConfig} from "../Common/interfaces";
import {AuthorCriteria} from "../Rule";
import {Footer, SubredditCacheConfig} from "../Common/interfaces";
import UserNotes from "./UserNotes";
import Mustache from "mustache";
import he from "he";
import {AuthorCriteria} from "../Author/Author";
export const WIKI_DESCRIM = 'wiki:';
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
export interface SubredditCacheOptions extends SubredditCacheConfig {
export interface SubredditResourceOptions extends SubredditCacheConfig, Footer {
enabled: boolean;
subreddit: Subreddit,
logger: Logger;
}
export class SubredditResources {
export interface SubredditResourceSetOptions extends SubredditCacheConfig, Footer {
enabled: boolean;
protected authorTTL: number;
protected useSubredditAuthorCache: boolean;
protected wikiTTL: number;
}
export class SubredditResources {
enabled!: boolean;
protected authorTTL!: number;
protected useSubredditAuthorCache!: boolean;
protected wikiTTL!: number;
name: string;
protected logger: Logger;
userNotes: UserNotes;
footer!: false | string;
subreddit: Subreddit
constructor(name: string, options: SubredditCacheOptions) {
constructor(name: string, options: SubredditResourceOptions) {
const {
subreddit,
logger,
enabled = true,
userNotesTTL = 60000,
} = options || {};
this.subreddit = subreddit;
this.name = name;
if (logger === undefined) {
const alogger = winston.loggers.get('default')
this.logger = alogger.child({labels: [this.name, 'Resource Cache']}, mergeArr);
} else {
this.logger = logger.child({labels: ['Resource Cache']}, mergeArr);
}
this.userNotes = new UserNotes(enabled ? userNotesTTL : 0, this.subreddit, this.logger)
this.setOptions(options);
}
setOptions (options: SubredditResourceSetOptions) {
const {
enabled = true,
authorTTL,
subreddit,
userNotesTTL = 60000,
wikiTTL = 300000, // 5 minutes
logger,
footer = DEFAULT_FOOTER
} = options || {};
this.footer = footer;
this.enabled = manager.enabled ? enabled : false;
if (authorTTL === undefined) {
this.useSubredditAuthorCache = false;
@@ -51,16 +81,7 @@ export class SubredditResources {
this.authorTTL = authorTTL;
}
this.wikiTTL = wikiTTL;
this.userNotes = new UserNotes(enabled ? userNotesTTL : 0, subreddit, logger);
this.name = name;
if (logger === undefined) {
const alogger = winston.loggers.get('default')
this.logger = alogger.child({labels: [this.name, 'Resource Cache']}, mergeArr);
} else {
this.logger = logger.child({labels: ['Resource Cache']}, mergeArr);
}
this.userNotes.notesTTL = enabled ? userNotesTTL : 0;
}
async getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
@@ -101,38 +122,69 @@ export class SubredditResources {
}) as unknown as Promise<Submission[]>;
}
async getContent(val: string, subreddit: Subreddit): Promise<string> {
const hasWiki = val.trim().substring(0, WIKI_DESCRIM.length) === WIKI_DESCRIM;
if (!hasWiki) {
async getContent(val: string, subredditArg?: Subreddit): Promise<string> {
const subreddit = subredditArg || this.subreddit;
let cacheKey;
const wikiContext = parseWikiContext(val);
if (wikiContext !== undefined) {
cacheKey = `${wikiContext.wiki}${wikiContext.subreddit !== undefined ? `|${wikiContext.subreddit}` : ''}`;
}
const extUrl = wikiContext === undefined ? parseExternalUrl(val) : undefined;
if (extUrl !== undefined) {
cacheKey = extUrl;
}
if (cacheKey === undefined) {
return val;
} else {
const useCache = this.enabled && this.wikiTTL > 0;
const wikiPath = val.trim().substring(WIKI_DESCRIM.length);
}
let hash = `${subreddit.display_name}-${wikiPath}`;
if (useCache) {
const cachedContent = cache.get(`${subreddit.display_name}-${wikiPath}`);
if (cachedContent !== null) {
this.logger.debug(`Cache Hit: ${wikiPath}`);
return cachedContent;
}
const useCache = this.enabled && this.wikiTTL > 0;
// try to get cached value first
let hash = `${subreddit.display_name}-${cacheKey}`;
if (useCache) {
const cachedContent = cache.get(hash);
if (cachedContent !== null) {
this.logger.debug(`Cache Hit: ${cacheKey}`);
return cachedContent;
}
}
let wikiContent: string;
// no cache hit, get from source
if (wikiContext !== undefined) {
let sub;
if (wikiContext.subreddit === undefined || wikiContext.subreddit.toLowerCase() === subreddit.display_name) {
sub = subreddit;
} else {
// @ts-ignore
const client = subreddit._r as Snoowrap;
sub = client.getSubreddit(wikiContext.subreddit);
}
try {
const wikiPage = subreddit.getWikiPage(wikiPath);
const wikiContent = await wikiPage.content_md;
if (useCache) {
cache.put(hash, wikiContent, this.wikiTTL);
}
return wikiContent;
const wikiPage = sub.getWikiPage(wikiContext.wiki);
wikiContent = await wikiPage.content_md;
} catch (err) {
const msg = `Could not read wiki page. Please ensure the page 'https://reddit.com${subreddit.display_name_prefixed}wiki/${wikiPath}' exists and is readable`;
const msg = `Could not read wiki page. Please ensure the page 'https://reddit.com${sub.display_name_prefixed}wiki/${wikiContext}' exists and is readable`;
this.logger.error(msg, err);
throw new LoggedError(msg);
}
} else {
try {
const response = await fetch(extUrl as string);
wikiContent = await response.text();
} catch (err) {
const msg = `Error occurred while trying to fetch the url ${extUrl}`;
this.logger.error(msg, err);
throw new LoggedError(msg);
}
}
if (useCache) {
cache.put(hash, wikiContent, this.wikiTTL);
}
return wikiContent;
}
async testAuthorCriteria(item: (Comment | Submission), authorOpts: AuthorCriteria, include = true) {
@@ -154,6 +206,20 @@ export class SubredditResources {
}
return result;
}
async generateFooter(item: Submission | Comment, actionFooter?: false | string)
{
let footer = actionFooter !== undefined ? actionFooter : this.footer;
if(footer === false) {
return '';
}
const subName = await item.subreddit.display_name;
const permaLink = `https://reddit.com${await item.permalink}`
const modmailLink = `https://www.reddit.com/message/compose?to=%2Fr%2F${subName}&message=${encodeURIComponent(permaLink)}`
const footerRawContent = await this.getContent(footer, item.subreddit);
return he.decode(Mustache.render(footerRawContent, {subName, permaLink, modmailLink, botLink: BOT_LINK}));
}
}
class SubredditResourcesManager {
@@ -168,7 +234,7 @@ class SubredditResourcesManager {
return undefined;
}
set(subName: string, initOptions: SubredditCacheOptions): SubredditResources {
set(subName: string, initOptions: SubredditResourceOptions): SubredditResources {
const resource = new SubredditResources(subName, initOptions);
this.resources.set(subName, resource);
return resource;

View File

@@ -0,0 +1,7 @@
import LoggedError from "./LoggedError";
class ConfigParseError extends LoggedError {
}
export default ConfigParseError

View File

@@ -0,0 +1,22 @@
import ExtendableError from "es6-error";
class InvalidRegexError extends ExtendableError {
constructor(regex: RegExp | RegExp[], val?: string, url?: string) {
const msgParts = [
'Regex(es) did not match the value given.',
];
let regArr = Array.isArray(regex) ? regex : [regex];
for(const r of regArr) {
msgParts.push(`Regex: ${r}`)
}
if (val !== undefined) {
msgParts.push(`Value: ${val}`);
}
if (url !== undefined) {
msgParts.push(`Sample regex: ${url}`);
}
super(msgParts.join('\r\n'));
}
}
export default InvalidRegexError;

View File

@@ -1,20 +1,32 @@
import Snoowrap, {Comment, RedditUser} from "snoowrap";
import Snoowrap, {RedditUser} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import Comment from "snoowrap/dist/objects/Comment";
import {Duration, DurationUnitsObjectType} from "dayjs/plugin/duration";
import dayjs, {Dayjs} from "dayjs";
import Mustache from "mustache";
import he from "he";
import {AuthorCriteria, RuleResult, UserNoteCriteria} from "../Rule";
import {RuleResult, UserNoteCriteria} from "../Rule";
import {
ActivityWindowType,
CommentState,
ActivityWindowType, CommentState, DomainInfo,
DurationVal,
SubmissionState,
TypedActivityStates
} from "../Common/interfaces";
import {isActivityWindowCriteria, normalizeName, truncateStringToLength} from "../util";
import {
compareDurationValue, comparisonTextOp,
isActivityWindowCriteria,
normalizeName, parseDuration,
parseDurationComparison, parseGenericValueComparison, parseGenericValueOrPercentComparison, parseSubredditName,
truncateStringToLength
} from "../util";
import UserNotes from "../Subreddit/UserNotes";
import {Logger} from "winston";
import InvalidRegexError from "./InvalidRegexError";
import SimpleError from "./SimpleError";
import {AuthorCriteria} from "../Author/Author";
import { URL } from "url";
export const BOT_LINK = 'https://www.reddit.com/r/ContextModBot/comments/o1dugk/introduction_to_contextmodbot_and_rcb';
export interface AuthorTypedActivitiesOptions extends AuthorActivitiesOptions {
type?: 'comment' | 'submission',
@@ -23,13 +35,16 @@ export interface AuthorTypedActivitiesOptions extends AuthorActivitiesOptions {
export interface AuthorActivitiesOptions {
window: ActivityWindowType | Duration
chunkSize?: number,
// TODO maybe move this into window
keepRemoved?: boolean,
}
export async function getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
const {
chunkSize: cs = 100,
window: optWindow
window: optWindow,
keepRemoved = true,
} = options;
let satisfiedCount: number | undefined,
@@ -40,8 +55,27 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
let durVal: DurationVal | undefined;
let duration: Duration | undefined;
let includes: string[] = [];
let excludes: string[] = [];
if(isActivityWindowCriteria(optWindow)) {
const { satisfyOn = 'any', count, duration } = optWindow;
const {
satisfyOn = 'any',
count,
duration,
subreddits: {
include = [],
exclude = [],
} = {},
} = optWindow;
includes = include.map(x => parseSubredditName(x).toLowerCase());
excludes = exclude.map(x => parseSubredditName(x).toLowerCase());
if(includes.length > 0 && excludes.length > 0) {
// TODO add logger so this can be logged...
// this.logger.warn('include and exclude both specified, exclude will be ignored');
}
satisfiedCount = count;
durVal = duration;
satisfy = satisfyOn
@@ -58,13 +92,20 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
if(durVal !== undefined) {
const endTime = dayjs();
if (!dayjs.isDuration(durVal)) {
// @ts-ignore
if (typeof durVal === 'object') {
duration = dayjs.duration(durVal);
}
if (!dayjs.isDuration(duration)) {
// TODO print object
throw new Error('window given was not a number, a valid ISO8601 duration, a Day.js duration, or well-formed Duration options');
if (!dayjs.isDuration(duration)) {
throw new Error('window value given was not a well-formed Duration object');
}
} else {
try {
duration = parseDuration(durVal);
} catch (e) {
if (e instanceof InvalidRegexError) {
throw new Error(`window value of '${durVal}' could not be parsed as a valid ISO8601 duration or DayJS duration shorthand (see Schema)`);
}
throw e;
}
}
satisfiedEndtime = endTime.subtract(duration.asMilliseconds(), 'milliseconds');
}
@@ -97,7 +138,26 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
let countOk = false,
timeOk = false;
const listSlice = listing.slice(offset - chunkSize)
let listSlice = listing.slice(offset - chunkSize)
// TODO partition list by filtered so we can log a debug statement with count of filtered out activities
if (includes.length > 0) {
listSlice = listSlice.filter(x => {
const actSub = x.subreddit.display_name.toLowerCase();
return includes.includes(actSub);
});
} else if (excludes.length > 0) {
listSlice = listSlice.filter(x => {
const actSub = x.subreddit.display_name.toLowerCase();
return !excludes.includes(actSub);
});
}
if(!keepRemoved) {
// snoowrap typings think 'removed' property does not exist on submission
// @ts-ignore
listSlice = listSlice.filter(x => !activityIsRemoved(x));
}
if (satisfiedCount !== undefined && items.length + listSlice.length >= satisfiedCount) {
// satisfied count
if(satisfy === 'any') {
@@ -157,11 +217,63 @@ export const getAuthorSubmissions = async (user: RedditUser, options: AuthorActi
return await getAuthorActivities(user, {...options, type: 'submission'}) as unknown as Promise<Submission[]>;
}
export const renderContent = async (content: string, data: (Submission | Comment), ruleResults: RuleResult[] = []) => {
export const renderContent = async (template: string, data: (Submission | Comment), ruleResults: RuleResult[] = [], usernotes: UserNotes) => {
const templateData: any = {
kind: data instanceof Submission ? 'submission' : 'comment',
author: await data.author.name,
// make this a getter so that if we don't load notes (and api call) if we don't need to
// didn't work either for some reason
// tried to get too fancy :(
// get notes() {
// return usernotes.getUserNotes(data.author).then((notesData) => {
// // return usable notes data with some stats
// const current = notesData.length > 0 ? notesData[notesData.length -1] : undefined;
// // group by type
// const grouped = notesData.reduce((acc: any, x) => {
// const {[x.noteType]: nt = []} = acc;
// return Object.assign(acc, {[x.noteType]: nt.concat(x)});
// }, {});
// return {
// data: notesData,
// current,
// ...grouped,
// };
// });
// },
// when i was trying to use mustache-async (didn't work)
// notes: async () => {
// const notesData = await usernotes.getUserNotes(data.author);
// // return usable notes data with some stats
// const current = notesData.length > 0 ? notesData[notesData.length -1] : undefined;
// // group by type
// const grouped = notesData.reduce((acc: any, x) => {
// const {[x.noteType]: nt = []} = acc;
// return Object.assign(acc, {[x.noteType]: nt.concat(x)});
// }, {});
// return {
// data: notesData,
// current,
// ...grouped,
// };
// },
permalink: data.permalink,
botLink: BOT_LINK,
}
if(template.includes('{{item.notes')) {
// we need to get notes
const notesData = await usernotes.getUserNotes(data.author);
// return usable notes data with some stats
const current = notesData.length > 0 ? notesData[notesData.length -1] : undefined;
// group by type
const grouped = notesData.reduce((acc: any, x) => {
const {[x.noteType]: nt = []} = acc;
return Object.assign(acc, {[x.noteType]: nt.concat(x)});
}, {});
templateData.notes = {
data: notesData,
current,
...grouped,
};
}
if (data instanceof Submission) {
templateData.url = data.url;
@@ -191,7 +303,9 @@ export const renderContent = async (content: string, data: (Submission | Comment
};
}, {});
return he.decode(Mustache.render(content, {item: templateData, rules: normalizedRuleResults}));
const view = {item: templateData, rules: normalizedRuleResults};
const rendered = Mustache.render(template, view) as string;
return he.decode(rendered);
}
export const testAuthorCriteria = async (item: (Comment | Submission), authorOpts: AuthorCriteria, include = true, userNotes: UserNotes) => {
@@ -252,7 +366,59 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
const mods: RedditUser[] = await item.subreddit.getModerators();
const isModerator = mods.some(x => x.name === item.author.name);
const modMatch = authorOpts.isMod === isModerator;
if ((include && !modMatch) || (!include && !modMatch)) {
if ((include && !modMatch) || (!include && modMatch)) {
return false;
}
break;
case 'age':
const ageTest = compareDurationValue(parseDurationComparison(await authorOpts.age as string), dayjs.unix(await item.author.created));
if ((include && !ageTest) || (!include && ageTest)) {
return false;
}
break;
case 'linkKarma':
const lkCompare = parseGenericValueOrPercentComparison(await authorOpts.linkKarma as string);
let lkMatch;
if(lkCompare.isPercent) {
// @ts-ignore
const tk = author.total_karma as number;
lkMatch = comparisonTextOp(author.link_karma / tk, lkCompare.operator, lkCompare.value/100);
} else {
lkMatch = comparisonTextOp(author.link_karma, lkCompare.operator, lkCompare.value);
}
if ((include && !lkMatch) || (!include && lkMatch)) {
return false;
}
break;
case 'commentKarma':
const ckCompare = parseGenericValueOrPercentComparison(await authorOpts.commentKarma as string);
let ckMatch;
if(ckCompare.isPercent) {
// @ts-ignore
const ck = author.total_karma as number;
ckMatch = comparisonTextOp(author.comment_karma / ck, ckCompare.operator, ckCompare.value/100);
} else {
ckMatch = comparisonTextOp(author.comment_karma, ckCompare.operator, ckCompare.value);
}
if ((include && !ckMatch) || (!include && ckMatch)) {
return false;
}
break;
case 'totalKarma':
const tkCompare = parseGenericValueComparison(await authorOpts.totalKarma as string);
if(tkCompare.isPercent) {
throw new SimpleError(`'totalKarma' value on AuthorCriteria cannot be a percentage`);
}
// @ts-ignore
const totalKarma = author.total_karma as number;
const tkMatch = comparisonTextOp(totalKarma, tkCompare.operator, tkCompare.value);
if ((include && !tkMatch) || (!include && tkMatch)) {
return false;
}
break;
case 'verified':
const vMatch = await author.has_verified_mail === authorOpts.verified as boolean;
if ((include && !vMatch) || (!include && vMatch)) {
return false;
}
break;
@@ -260,7 +426,9 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
const notes = await userNotes.getUserNotes(item.author);
const notePass = () => {
for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) {
const {count = 1, order = 'descending', search = 'current', type} = noteCriteria;
const {count = '>= 1', search = 'current', type} = noteCriteria;
const {value, operator, isPercent, extra = ''} = parseGenericValueOrPercentComparison(count);
const order = extra.includes('asc') ? 'ascending' : 'descending';
switch (search) {
case 'current':
if (notes.length > 0 && notes[notes.length - 1].noteType === type) {
@@ -280,13 +448,20 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
} else {
currCount = 0;
}
if (currCount >= count) {
if(isPercent) {
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
}
if (comparisonTextOp(currCount, operator, value)) {
return true;
}
}
break;
case 'total':
if (notes.filter(x => x.noteType === type).length >= count) {
if(isPercent) {
if(comparisonTextOp(notes.filter(x => x.noteType === type).length / notes.length, operator, value/100)) {
return true;
}
} else if(comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
return true;
}
}
@@ -351,21 +526,103 @@ export const getSubmissionFromComment = async (item: Comment): Promise<Submissio
}
}
export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain = false) => {
let domain = sub.domain;
const SPOTIFY_PODCAST_AUTHOR_REGEX: RegExp = /this episode from (?<author>.*?) on Spotify./;
const SPOTIFY_PODCAST_AUTHOR_REGEX_URL = 'https://regexr.com/61c2f';
const SPOTIFY_MUSIC_AUTHOR_REGEX: RegExp = /Listen to .*? on Spotify.\s(?<author>.+?)\s·\s(?<mediaType>.+?)\s/;
const SPOTIFY_MUSIC_AUTHOR_REGEX_URL = 'https://regexr.com/61c2r';
const ANCHOR_AUTHOR_REGEX: RegExp = /by (?<author>.+?)$/;
const ANCHOR_AUTHOR_REGEX_URL = 'https://regexr.com/61c31';
export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain = false): DomainInfo => {
let domain: string = '';
let displayDomain: string = '';
let domainIdents: string[] = useParentMediaDomain ? [sub.domain] : [];
let provider: string | undefined;
let mediaType: string | undefined;
if (!useParentMediaDomain && sub.secure_media?.oembed !== undefined) {
const {
author_url,
author_name,
description,
provider_name,
} = sub.secure_media?.oembed;
if (author_name !== undefined) {
domain = author_name;
} else if (author_url !== undefined) {
domain = author_url;
switch(provider_name) {
case 'Spotify':
if(description !== undefined) {
let match = description.match(SPOTIFY_PODCAST_AUTHOR_REGEX);
if(match !== null) {
const {author} = match.groups as any;
displayDomain = author;
domainIdents.push(author);
mediaType = 'Podcast';
} else {
match = description.match(SPOTIFY_MUSIC_AUTHOR_REGEX);
if(match !== null) {
const {author, mediaType: mt} = match.groups as any;
displayDomain = author;
domainIdents.push(author);
mediaType = mt.toLowerCase();
}
}
}
break;
case 'Anchor FM Inc.':
if(author_name !== undefined) {
let match = author_name.match(ANCHOR_AUTHOR_REGEX);
if(match !== null) {
const {author} = match.groups as any;
displayDomain = author;
domainIdents.push(author);
mediaType = 'podcast';
}
}
break;
case 'YouTube':
mediaType = 'Video/Audio';
break;
default:
// nah
}
// handles yt, vimeo, twitter fine
if(displayDomain === '') {
if (author_name !== undefined) {
domainIdents.push(author_name);
if (displayDomain === '') {
displayDomain = author_name;
}
}
if (author_url !== undefined) {
domainIdents.push(author_url);
domain = author_url;
if (displayDomain === '') {
displayDomain = author_url;
}
}
}
if(displayDomain === '') {
// we have media but could not parse stuff for some reason just use url
const u = new URL(sub.url);
displayDomain = u.pathname;
domainIdents.push(u.pathname);
}
provider = provider_name;
} else if(sub.secure_media?.type !== undefined) {
domainIdents.push(sub.secure_media?.type);
domain = sub.secure_media?.type;
} else {
domain = sub.domain;
}
return domain;
if(domain === '') {
domain = sub.domain;
}
if (displayDomain === '') {
displayDomain = domain;
}
return {display: displayDomain, domain, aliases: domainIdents, provider, mediaType};
}
export const isItem = (item: Submission | Comment, stateCriteria: TypedActivityStates, logger: Logger): [boolean, SubmissionState|CommentState|undefined] => {
@@ -380,18 +637,48 @@ export const isItem = (item: Submission | Comment, stateCriteria: TypedActivityS
for (const k of Object.keys(crit)) {
// @ts-ignore
if (crit[k] !== undefined) {
// @ts-ignore
if (item[k] !== undefined) {
// @ts-ignore
if (item[k] !== crit[k]) {
return [false, crit];
}
} else {
log.warn(`Tried to test for Item property '${k}' but it did not exist`);
switch(k) {
case 'removed':
const removed = activityIsRemoved(item);
if (removed !== crit['removed']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${removed}`)
return [false, crit];
}
break;
case 'deleted':
const deleted = activityIsDeleted(item);
if (deleted !== crit['deleted']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${deleted}`)
return [false, crit];
}
break;
case 'filtered':
const filtered = activityIsFiltered(item);
if (filtered !== crit['filtered']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${filtered}`)
return [false, crit];
}
break;
default:
// @ts-ignore
if (item[k] !== undefined) {
// @ts-ignore
if (item[k] !== crit[k]) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item[k]}`)
return [false, crit];
}
} else {
log.warn(`Tried to test for Item property '${k}' but it did not exist`);
}
break;
}
}
}
log.verbose(`itemIs passed: ${JSON.stringify(crit)}`);
log.debug(`Passed: ${JSON.stringify(crit)}`);
return [true, crit];
})() as [boolean, SubmissionState|CommentState|undefined];
if (pass) {
@@ -400,3 +687,30 @@ export const isItem = (item: Submission | Comment, stateCriteria: TypedActivityS
}
return [false, undefined];
}
export const activityIsRemoved = (item: Submission|Comment): boolean => {
if(item instanceof Submission) {
// when automod filters a post it gets this category
return item.banned_at_utc !== null && item.removed_by_category !== 'automod_filtered';
}
// when automod filters a comment item.removed === false
// so if we want to processing filtered comments we need to check for this
return item.banned_at_utc !== null && item.removed;
}
export const activityIsFiltered = (item: Submission|Comment): boolean => {
if(item instanceof Submission) {
// when automod filters a post it gets this category
return item.banned_at_utc !== null && item.removed_by_category === 'automod_filtered';
}
// when automod filters a comment item.removed === false
// so if we want to processing filtered comments we need to check for this
return item.banned_at_utc !== null && !item.removed;
}
export const activityIsDeleted = (item: Submission|Comment): boolean => {
if(item instanceof Submission) {
return item.removed_by_category === 'deleted';
}
return item.author.name === '[deleted]'
}

View File

@@ -4,6 +4,8 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc.js';
import dduration from 'dayjs/plugin/duration.js';
import relTime from 'dayjs/plugin/relativeTime.js';
import sameafter from 'dayjs/plugin/isSameOrAfter.js';
import samebefore from 'dayjs/plugin/isSameOrBefore.js';
import {Manager} from "./Subreddit/Manager";
import {Command} from 'commander';
import {checks, getUniversalOptions, limit} from "./Utils/CommandConfig";
@@ -14,6 +16,8 @@ import {COMMENT_URL_ID, parseLinkIdentifier, SUBMISSION_URL_ID} from "./util";
dayjs.extend(utc);
dayjs.extend(dduration);
dayjs.extend(relTime);
dayjs.extend(sameafter);
dayjs.extend(samebefore);
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
@@ -127,3 +131,6 @@ for (const o of getUniversalOptions()) {
console.log(err);
}
}());
export {Author} from "./Author/Author";
export {AuthorCriteria} from "./Author/Author";
export {AuthorOptions} from "./Author/Author";

View File

@@ -1,23 +1,19 @@
import winston, {Logger} from "winston";
import jsonStringify from 'safe-stable-stringify';
import dayjs from 'dayjs';
import {RulePremise, RuleResult} from "./Rule";
import dayjs, {Dayjs, OpUnitType} from 'dayjs';
import {isRuleSetResult, RulePremise, RuleResult, RuleSetResult} from "./Rule";
import deepEqual from "fast-deep-equal";
import utc from 'dayjs/plugin/utc.js';
import dduration from 'dayjs/plugin/duration.js';
import {Duration} from 'dayjs/plugin/duration.js';
import Ajv from "ajv";
import {InvalidOptionArgumentError} from "commander";
import Submission from "snoowrap/dist/objects/Submission";
import {Comment} from "snoowrap";
import {inflateSync, deflateSync} from "zlib";
import pako from "pako";
import {ActivityWindowCriteria} from "./Common/interfaces";
import {ActivityWindowCriteria, DurationComparison, GenericComparison, StringOperator} from "./Common/interfaces";
import JSON5 from "json5";
import yaml, {JSON_SCHEMA} from "js-yaml";
import SimpleError from "./Utils/SimpleError";
dayjs.extend(utc);
dayjs.extend(dduration);
import InvalidRegexError from "./Utils/InvalidRegexError";
const {format} = winston;
const {combine, printf, timestamp, label, splat, errors} = format;
@@ -40,6 +36,9 @@ const errorAwareFormat = {
}
}
export const PASS = '✔';
export const FAIL = '✘';
export const truncateStringToLength = (length: number, truncStr = '...') => (str: string) => str.length > length ? `${str.slice(0, length - truncStr.length - 1)}${truncStr}` : str;
export const defaultFormat = printf(({
@@ -59,12 +58,17 @@ export const defaultFormat = printf(({
let stackMsg = '';
if (stack !== undefined) {
const stackArr = stack.split('\n');
msg = stackArr[0];
const stackTop = stackArr[0];
const cleanedStack = stackArr
.slice(1) // don't need actual error message since we are showing it as msg
.map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
.join('\n'); // rejoin with newline to preserve formatting
stackMsg = `\n${cleanedStack}`;
if(msg === undefined || msg === null || typeof message === 'object') {
msg = stackTop;
} else {
stackMsg = `\n${stackTop}${stackMsg}`
}
}
let nodes = labels;
@@ -207,23 +211,27 @@ export const ruleNamesFromResults = (results: RuleResult[]) => {
return results.map(x => x.name || x.premise.kind).join(' | ')
}
export const createAjvFactory = (logger: Logger) => {
return new Ajv({logger: logger, verbose: true, strict: "log", allowUnionTypes: true});
export const triggeredIndicator = (val: boolean | null): string => {
if(val === null) {
return '-';
}
return val ? PASS : FAIL;
}
export const comparisonTextOp = (val1: number, strOp: string, val2: number): boolean => {
switch (strOp) {
case '>':
return val1 > val2;
case '>=':
return val1 >= val2;
case '<':
return val1 < val2;
case '<=':
return val1 <= val2;
default:
throw new Error(`${strOp} was not a recognized operator`);
}
export const resultsSummary = (results: (RuleResult|RuleSetResult)[], topLevelCondition: 'OR' | 'AND'): string => {
const parts: string[] = results.map((x) => {
if(isRuleSetResult(x)) {
return `${triggeredIndicator(x.triggered)} (${resultsSummary(x.results, x.condition)}${x.results.length === 1 ? ` [${x.condition}]` : ''})`;
}
const res = x as RuleResult;
return `${triggeredIndicator(x.triggered)} ${res.name}`;
});
return parts.join(` ${topLevelCondition} `)
//return results.map(x => x.name || x.premise.kind).join(' | ')
}
export const createAjvFactory = (logger: Logger) => {
return new Ajv({logger: logger, verbose: true, strict: "log", allowUnionTypes: true});
}
export const percentFromString = (str: string): number => {
@@ -234,26 +242,34 @@ export const percentFromString = (str: string): number => {
return n / 100;
}
export const formatNumber = ( val: number|string, options: any = {} ) => {
export interface numberFormatOptions {
toFixed: number,
defaultVal?: any,
prefix?: string,
suffix?: string,
round?: {
type?: string,
enable: boolean,
indicate?: boolean,
}
}
export const formatNumber = (val: number | string, options?: numberFormatOptions) => {
const {
toFixed = 2,
toFixed = 2,
defaultVal = null,
prefix = '',
suffix = '',
round = {
type: 'round',
enable: false,
indicate: true,
}
} = options;
let parsedVal = typeof val === 'number' ? val : Number.parseFloat( val );
if(Number.isNaN( parsedVal )) {
prefix = '',
suffix = '',
round,
} = options || {};
let parsedVal = typeof val === 'number' ? val : Number.parseFloat(val);
if (Number.isNaN(parsedVal)) {
return defaultVal;
}
let prefixStr = prefix;
const { enable = true, indicate = true, type = 'round' } = round;
if(enable && !Number.isInteger(parsedVal)) {
switch(type) {
const {enable = false, indicate = true, type = 'round'} = round || {};
if (enable && !Number.isInteger(parsedVal)) {
switch (type) {
case 'round':
parsedVal = Math.round(parsedVal);
break;
@@ -263,14 +279,14 @@ export const formatNumber = ( val: number|string, options: any = {} ) => {
case 'floor':
parsedVal = Math.floor(parsedVal);
}
if(indicate) {
if (indicate) {
prefixStr = `~${prefix}`;
}
}
const localeString = parsedVal.toLocaleString( undefined, {
const localeString = parsedVal.toLocaleString(undefined, {
minimumFractionDigits: toFixed,
maximumFractionDigits: toFixed,
} );
});
return `${prefixStr}${localeString}${suffix}`;
};
@@ -379,7 +395,7 @@ export const parseFromJsonOrYamlToObject = (content: string): [object?, Error?,
obj = yaml.load(content, {schema: JSON_SCHEMA, json: true});
const oType = obj === null ? 'null' : typeof obj;
if (oType !== 'object') {
yamlErr = new Error(`Parsing as yaml produced data of type '${oType}' (expected 'object')`);
yamlErr = new SimpleError(`Parsing as yaml produced data of type '${oType}' (expected 'object')`);
obj = undefined;
}
} catch (err) {
@@ -389,10 +405,151 @@ export const parseFromJsonOrYamlToObject = (content: string): [object?, Error?,
return [obj, jsonErr, yamlErr];
}
export const generateFooter = async (item: Submission | Comment) => {
const subName = await item.subreddit.display_name;
// TODO customize modmail message based on action being peformed
const modmailLink = `https://www.reddit.com/message/compose?to=%2Fr%2F${subName}&message=Reminder:%20If+you+are+messaging+about+a+post+removal+,+please+include+the%20post%20URL%20somewhere%20in%20the%20message.`
return `\r\n*****\r\nThis action was performed by [a bot.](https://www.reddit.com/r/ContextModBot/comments/o1dugk/introduction_to_contextmodbot_and_rcb/) Mention a moderator or [send a modmail](${modmailLink}) if you any ideas, questions , or concerns about this action.`
export const comparisonTextOp = (val1: number, strOp: string, val2: number): boolean => {
switch (strOp) {
case '>':
return val1 > val2;
case '>=':
return val1 >= val2;
case '<':
return val1 < val2;
case '<=':
return val1 <= val2;
default:
throw new Error(`${strOp} was not a recognized operator`);
}
}
const GENERIC_VALUE_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)(?<extra>\s+.*)*$/
const GENERIC_VALUE_COMPARISON_URL = 'https://regexr.com/60dq4';
export const parseGenericValueComparison = (val: string): GenericComparison => {
const matches = val.match(GENERIC_VALUE_COMPARISON);
if (matches === null) {
throw new InvalidRegexError(GENERIC_VALUE_COMPARISON, val, GENERIC_VALUE_COMPARISON_URL)
}
const groups = matches.groups as any;
return {
operator: groups.opStr as StringOperator,
value: Number.parseFloat(groups.value),
isPercent: false,
extra: groups.extra,
displayText: `${groups.opStr} ${groups.value}`
}
}
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%?)(?<extra>.*)$/
const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/60a16';
export const parseGenericValueOrPercentComparison = (val: string): GenericComparison => {
const matches = val.match(GENERIC_VALUE_PERCENT_COMPARISON);
if (matches === null) {
throw new InvalidRegexError(GENERIC_VALUE_PERCENT_COMPARISON, val, GENERIC_VALUE_PERCENT_COMPARISON_URL)
}
const groups = matches.groups as any;
return {
operator: groups.opStr as StringOperator,
value: Number.parseFloat(groups.value),
isPercent: groups.percent !== '',
extra: groups.extra,
displayText: `${groups.opStr} ${groups.value}${groups.percent === undefined ? '': '%'}`
}
}
export const dateComparisonTextOp = (val1: Dayjs, strOp: StringOperator, val2: Dayjs, granularity?: OpUnitType): boolean => {
switch (strOp) {
case '>':
return val1.isBefore(val2, granularity);
case '>=':
return val1.isSameOrBefore(val2, granularity);
case '<':
return val1.isAfter(val2, granularity);
case '<=':
return val1.isSameOrAfter(val2, granularity);
default:
throw new Error(`${strOp} was not a recognized operator`);
}
}
const ISO8601_REGEX: RegExp = /^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
const DURATION_REGEX: RegExp = /^\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$/;
export const parseDuration = (val: string): Duration => {
let matches = val.match(DURATION_REGEX);
if (matches !== null) {
const groups = matches.groups as any;
const dur: Duration = dayjs.duration(groups.time, groups.unit);
if (!dayjs.isDuration(dur)) {
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
}
return dur;
}
matches = val.match(ISO8601_REGEX);
if (matches !== null) {
const dur: Duration = dayjs.duration(val);
if (!dayjs.isDuration(dur)) {
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
}
return dur;
}
throw new InvalidRegexError([DURATION_REGEX, ISO8601_REGEX], val)
}
/**
* Named groups: operator, time, unit
* */
const DURATION_COMPARISON_REGEX: RegExp = /^\s*(?<opStr>>|>=|<|<=)\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$/;
const DURATION_COMPARISON_REGEX_URL = 'https://regexr.com/609n8';
export const parseDurationComparison = (val: string): DurationComparison => {
const matches = val.match(DURATION_COMPARISON_REGEX);
if (matches === null) {
throw new InvalidRegexError(DURATION_COMPARISON_REGEX, val, DURATION_COMPARISON_REGEX_URL)
}
const groups = matches.groups as any;
const dur: Duration = dayjs.duration(groups.time, groups.unit);
if (!dayjs.isDuration(dur)) {
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
}
return {
operator: groups.opStr as StringOperator,
duration: dur
}
}
export const compareDurationValue = (comp: DurationComparison, date: Dayjs) => {
const dateToCompare = dayjs().subtract(comp.duration.asSeconds(), 'seconds');
return dateComparisonTextOp(date, comp.operator, dateToCompare);
}
const SUBREDDIT_NAME_REGEX: RegExp = /^\s*(?:\/r\/|r\/)*(\w+)*\s*$/;
const SUBREDDIT_NAME_REGEX_URL = 'https://regexr.com/61a1d';
export const parseSubredditName = (val:string): string => {
const matches = val.match(SUBREDDIT_NAME_REGEX);
if (matches === null) {
throw new InvalidRegexError(SUBREDDIT_NAME_REGEX, val, SUBREDDIT_NAME_REGEX_URL)
}
return matches[1] as string;
}
const WIKI_REGEX: RegExp = /^\s*wiki:(?<url>[^|]+)\|*(?<subreddit>[^\s]*)\s*$/;
const WIKI_REGEX_URL = 'https://regexr.com/61bq1';
const URL_REGEX: RegExp = /^\s*url:(?<url>[^\s]+)\s*$/;
const URL_REGEX_URL = 'https://regexr.com/61bqd';
export const parseWikiContext = (val: string) => {
const matches = val.match(WIKI_REGEX);
if (matches === null) {
return undefined;
}
const sub = (matches.groups as any).subreddit as string;
return {
wiki: (matches.groups as any).url as string,
subreddit: sub === '' ? undefined : parseSubredditName(sub)
};
}
export const parseExternalUrl = (val: string) => {
const matches = val.match(URL_REGEX);
if (matches === null) {
return undefined;
}
return (matches.groups as any).url as string;
}