mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 07:57:57 -05:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e2fee6d50 | ||
|
|
ed8be6dda2 | ||
|
|
00e38b5560 | ||
|
|
9cac11f436 | ||
|
|
f591c3a05a | ||
|
|
39fad91c7f | ||
|
|
529b8fc03e | ||
|
|
54eef5620d | ||
|
|
99537fbebb | ||
|
|
4c3f9ee082 | ||
|
|
5b028b6a45 | ||
|
|
859bcf9213 | ||
|
|
e790f7c260 | ||
|
|
20358294ce | ||
|
|
e0f18dc0a2 | ||
|
|
9a788a8323 | ||
|
|
bed9a9682a | ||
|
|
d39ce13209 | ||
|
|
4bd25e53b0 | ||
|
|
ac87d5acfa | ||
|
|
0f541f1961 | ||
|
|
db2be949b4 | ||
|
|
8c6b18cf4d | ||
|
|
add4204304 | ||
|
|
927d4ef07e | ||
|
|
b8c12009ee | ||
|
|
7f9b4ce6a0 | ||
|
|
ad8a668a08 | ||
|
|
84c5e97c92 | ||
|
|
03b2cb36ab | ||
|
|
93bdb89115 | ||
|
|
702e2ccccf | ||
|
|
631d67928d | ||
|
|
eea04344c0 | ||
|
|
7f29ade87b | ||
|
|
cced86381b | ||
|
|
01c575f2b2 | ||
|
|
f1d04d4718 | ||
|
|
6ca65079b3 | ||
|
|
73236e44ad | ||
|
|
4bef85e1e4 | ||
|
|
532f6aa3d8 | ||
|
|
e1e5b26264 | ||
|
|
46a583e20a | ||
|
|
24064dfe03 | ||
|
|
ad91901cc2 | ||
|
|
58c51e56b1 | ||
|
|
9850ccb8f3 | ||
|
|
79b82dab0f | ||
|
|
9c059beb85 | ||
|
|
88be7d8836 |
@@ -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
119
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": "*",
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
* */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
45
src/App.ts
45
src/App.ts
@@ -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
129
src/Author/Author.ts
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
440
src/Rule/AttributionRule.ts
Normal 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'
|
||||
}
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
* */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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
16
src/Subreddit/Streams.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
7
src/Utils/ConfigParseError.ts
Normal file
7
src/Utils/ConfigParseError.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import LoggedError from "./LoggedError";
|
||||
|
||||
class ConfigParseError extends LoggedError {
|
||||
|
||||
}
|
||||
|
||||
export default ConfigParseError
|
||||
22
src/Utils/InvalidRegexError.ts
Normal file
22
src/Utils/InvalidRegexError.ts
Normal 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;
|
||||
@@ -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]'
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
257
src/util.ts
257
src/util.ts
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user