mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 07:57:57 -05:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
910f7f79ef | ||
|
|
641892cd3e | ||
|
|
1dfb9779e7 | ||
|
|
40111c54a2 | ||
|
|
b4745e3b45 | ||
|
|
838da497ce | ||
|
|
01755eada5 | ||
|
|
1ff59ad6e8 | ||
|
|
d8fd8e6140 | ||
|
|
255ffdb417 | ||
|
|
f0199366a0 | ||
|
|
20c724cab5 | ||
|
|
a670975f14 | ||
|
|
ee13feaf57 | ||
|
|
23a24b4448 | ||
|
|
a11b667d5e | ||
|
|
269b1620b9 | ||
|
|
6dee734440 | ||
|
|
3aea422eff | ||
|
|
e707e5a9a8 | ||
|
|
2a24eea3a5 | ||
|
|
8ad8297c0e | ||
|
|
0b94a14ac1 | ||
|
|
a04e0d2a9b | ||
|
|
3a1348c370 | ||
|
|
507818037f | ||
|
|
2c1f6daf4f | ||
|
|
fef79472fe |
137
package-lock.json
generated
137
package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.8.0",
|
||||
"@stdlib/regexp-regexp": "^0.0.6",
|
||||
"ajv": "^7.2.4",
|
||||
"async": "^3.2.0",
|
||||
"autolinker": "^3.14.3",
|
||||
@@ -173,6 +174,112 @@
|
||||
"url": "https://github.com/sindresorhus/is?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/regexp-regexp": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/regexp-regexp/-/regexp-regexp-0.0.6.tgz",
|
||||
"integrity": "sha512-z/PfA7bpBe6FPNOAXEIu8Z5tePrJ1VXMMZBgcOIdxX2syM/tP4BuFUmeljb8zFXo0fnYxHlPuHqe7+SeIbV40Q==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stdlib/utils-define-nonenumerable-read-only-property": "^0.0.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/athan"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/types": {
|
||||
"version": "0.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/types/-/types-0.0.13.tgz",
|
||||
"integrity": "sha512-8aPkDtaJM/XZENqhoj7BYuwENLGyxz1xfLIcf2zct7kLZMi0rODzks3n65LEMIR9Rh3rFDXlwc35XvzEkTpmZQ==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/athan"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/utils-define-nonenumerable-read-only-property": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/utils-define-nonenumerable-read-only-property/-/utils-define-nonenumerable-read-only-property-0.0.6.tgz",
|
||||
"integrity": "sha512-j0NsSDFa0hUx9MtZ1mMj+Ak5nxI+cU7dEMB6C6JgJhkKDz9Hg1iT/KhzlGPaIn1Tsycvyydge906qVepjspXgQ==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stdlib/types": "^0.0.x",
|
||||
"@stdlib/utils-define-property": "^0.0.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/athan"
|
||||
}
|
||||
},
|
||||
"node_modules/@stdlib/utils-define-property": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/utils-define-property/-/utils-define-property-0.0.7.tgz",
|
||||
"integrity": "sha512-druB11NwdtkDTP35lx8qeYnqXPcyddde78h1hflVB0OsKtFofUVbg0oo/pm30gBYFCWWtIhxL6iC7feeuUU/yQ==",
|
||||
"os": [
|
||||
"aix",
|
||||
"darwin",
|
||||
"freebsd",
|
||||
"linux",
|
||||
"macos",
|
||||
"openbsd",
|
||||
"sunos",
|
||||
"win32",
|
||||
"windows"
|
||||
],
|
||||
"dependencies": {
|
||||
"@stdlib/types": "^0.0.x"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0",
|
||||
"npm": ">2.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/athan"
|
||||
}
|
||||
},
|
||||
"node_modules/@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
@@ -4234,6 +4341,36 @@
|
||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.0.1.tgz",
|
||||
"integrity": "sha512-Qm9hBEBu18wt1PO2flE7LPb30BHMQt1eQgbV76YntdNk73XZGpn3izvGTYxbGgzXKgbCjiia0uxTd3aTNQrY/g=="
|
||||
},
|
||||
"@stdlib/regexp-regexp": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/regexp-regexp/-/regexp-regexp-0.0.6.tgz",
|
||||
"integrity": "sha512-z/PfA7bpBe6FPNOAXEIu8Z5tePrJ1VXMMZBgcOIdxX2syM/tP4BuFUmeljb8zFXo0fnYxHlPuHqe7+SeIbV40Q==",
|
||||
"requires": {
|
||||
"@stdlib/utils-define-nonenumerable-read-only-property": "^0.0.x"
|
||||
}
|
||||
},
|
||||
"@stdlib/types": {
|
||||
"version": "0.0.13",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/types/-/types-0.0.13.tgz",
|
||||
"integrity": "sha512-8aPkDtaJM/XZENqhoj7BYuwENLGyxz1xfLIcf2zct7kLZMi0rODzks3n65LEMIR9Rh3rFDXlwc35XvzEkTpmZQ=="
|
||||
},
|
||||
"@stdlib/utils-define-nonenumerable-read-only-property": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/utils-define-nonenumerable-read-only-property/-/utils-define-nonenumerable-read-only-property-0.0.6.tgz",
|
||||
"integrity": "sha512-j0NsSDFa0hUx9MtZ1mMj+Ak5nxI+cU7dEMB6C6JgJhkKDz9Hg1iT/KhzlGPaIn1Tsycvyydge906qVepjspXgQ==",
|
||||
"requires": {
|
||||
"@stdlib/types": "^0.0.x",
|
||||
"@stdlib/utils-define-property": "^0.0.x"
|
||||
}
|
||||
},
|
||||
"@stdlib/utils-define-property": {
|
||||
"version": "0.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@stdlib/utils-define-property/-/utils-define-property-0.0.7.tgz",
|
||||
"integrity": "sha512-druB11NwdtkDTP35lx8qeYnqXPcyddde78h1hflVB0OsKtFofUVbg0oo/pm30gBYFCWWtIhxL6iC7feeuUU/yQ==",
|
||||
"requires": {
|
||||
"@stdlib/types": "^0.0.x"
|
||||
}
|
||||
},
|
||||
"@szmarczak/http-timer": {
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz",
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.8.0",
|
||||
"@stdlib/regexp-regexp": "^0.0.6",
|
||||
"ajv": "^7.2.4",
|
||||
"async": "^3.2.0",
|
||||
"autolinker": "^3.14.3",
|
||||
|
||||
@@ -4,6 +4,7 @@ import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {ActionProcessResult, Footer, RequiredRichContent, RichContent} from "../Common/interfaces";
|
||||
import {RuleResult} from "../Rule";
|
||||
import {truncateStringToLength} from "../util";
|
||||
|
||||
export class CommentAction extends Action {
|
||||
content: string;
|
||||
@@ -66,9 +67,18 @@ export class CommentAction extends Action {
|
||||
// @ts-ignore
|
||||
await reply.distinguish({sticky: this.sticky});
|
||||
}
|
||||
let modifiers = [];
|
||||
if(this.distinguish) {
|
||||
modifiers.push('Distinguished');
|
||||
}
|
||||
if(this.sticky) {
|
||||
modifiers.push('Stickied');
|
||||
}
|
||||
const modifierStr = modifiers.length === 0 ? '' : `[${modifiers.join(' | ')}]`;
|
||||
return {
|
||||
dryRun,
|
||||
success: true,
|
||||
result: `${modifierStr}${this.lock ? ' - Locked Author\'s Activity - ' : ''}${truncateStringToLength(100)(body)}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,15 @@ import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {ActionProcessResult, Footer, RequiredRichContent, RichContent} from "../Common/interfaces";
|
||||
import {RuleResult} from "../Rule";
|
||||
import {asSubmission, boolToString, isSubmission} from "../util";
|
||||
import {
|
||||
asSubmission,
|
||||
boolToString,
|
||||
isSubmission,
|
||||
parseRedditEntity,
|
||||
REDDIT_ENTITY_REGEX_URL,
|
||||
truncateStringToLength
|
||||
} from "../util";
|
||||
import SimpleError from "../Utils/SimpleError";
|
||||
|
||||
export class MessageAction extends Action {
|
||||
content: string;
|
||||
@@ -14,6 +22,7 @@ export class MessageAction extends Action {
|
||||
footer?: false | string;
|
||||
|
||||
title?: string;
|
||||
to?: string;
|
||||
asSubreddit: boolean;
|
||||
|
||||
constructor(options: MessageActionOptions) {
|
||||
@@ -23,7 +32,9 @@ export class MessageAction extends Action {
|
||||
asSubreddit,
|
||||
title,
|
||||
footer,
|
||||
to,
|
||||
} = options;
|
||||
this.to = to;
|
||||
this.footer = footer;
|
||||
this.content = content;
|
||||
this.asSubreddit = asSubreddit;
|
||||
@@ -42,11 +53,30 @@ export class MessageAction extends Action {
|
||||
const footer = await this.resources.generateFooter(item, this.footer);
|
||||
|
||||
const renderedContent = `${body}${footer}`;
|
||||
// @ts-ignore
|
||||
const author = await item.author.fetch() as RedditUser;
|
||||
|
||||
let recipient = item.author.name;
|
||||
if(this.to !== undefined) {
|
||||
// parse to value
|
||||
try {
|
||||
const entityData = parseRedditEntity(this.to);
|
||||
if(entityData.type === 'user') {
|
||||
recipient = entityData.name;
|
||||
} else {
|
||||
recipient = `/r/${entityData.name}`;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(`'to' field for message was not in a valid format. See ${REDDIT_ENTITY_REGEX_URL} for valid examples`);
|
||||
this.logger.error(err);
|
||||
err.logged = true;
|
||||
throw err;
|
||||
}
|
||||
if(recipient.includes('/r/') && this.asSubreddit) {
|
||||
throw new SimpleError(`Cannot send a message as a subreddit to another subreddit. Requested recipient: ${recipient}`);
|
||||
}
|
||||
}
|
||||
|
||||
const msgOpts: ComposeMessageParams = {
|
||||
to: author,
|
||||
to: recipient,
|
||||
text: renderedContent,
|
||||
// @ts-ignore
|
||||
fromSubreddit: this.asSubreddit ? await item.subreddit.fetch() : undefined,
|
||||
@@ -54,7 +84,7 @@ export class MessageAction extends Action {
|
||||
};
|
||||
|
||||
const msgPreview = `\r\n
|
||||
TO: ${author.name}\r\n
|
||||
TO: ${recipient}\r\n
|
||||
Subject: ${msgOpts.subject}\r\n
|
||||
Sent As Modmail: ${boolToString(this.asSubreddit)}\r\n\r\n
|
||||
${renderedContent}`;
|
||||
@@ -67,6 +97,7 @@ export class MessageAction extends Action {
|
||||
return {
|
||||
dryRun,
|
||||
success: true,
|
||||
result: truncateStringToLength(200)(msgPreview)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -77,6 +108,24 @@ export interface MessageActionConfig extends RequiredRichContent, Footer {
|
||||
* */
|
||||
asSubreddit: boolean
|
||||
|
||||
/**
|
||||
* Entity to send message to.
|
||||
*
|
||||
* If not present Message be will sent to the Author of the Activity being checked.
|
||||
*
|
||||
* Valid formats:
|
||||
*
|
||||
* * `aUserName` -- send to /u/aUserName
|
||||
* * `u/aUserName` -- send to /u/aUserName
|
||||
* * `r/aSubreddit` -- sent to modmail of /r/aSubreddit
|
||||
*
|
||||
* **Note:** Reddit does not support sending a message AS a subreddit TO another subreddit
|
||||
*
|
||||
* @pattern ^\s*(\/[ru]\/|[ru]\/)*(\w+)*\s*$
|
||||
* @examples ["aUserName","u/aUserName","r/aSubreddit"]
|
||||
* */
|
||||
to?: string
|
||||
|
||||
/**
|
||||
* The title of the message
|
||||
*
|
||||
|
||||
@@ -36,7 +36,8 @@ export class ReportAction extends Action {
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
success: true
|
||||
success: true,
|
||||
result: truncatedContent
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,15 @@ export class FlairAction extends Action {
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionProcessResult> {
|
||||
const dryRun = runtimeDryrun || this.dryRun;
|
||||
let flairParts = [];
|
||||
if(this.text !== '') {
|
||||
flairParts.push(`Text: ${this.text}`);
|
||||
}
|
||||
if(this.css !== '') {
|
||||
flairParts.push(`CSS: ${this.css}`);
|
||||
}
|
||||
const flairSummary = flairParts.length === 0 ? 'No flair (unflaired)' : flairParts.join(' | ');
|
||||
this.logger.verbose(flairSummary);
|
||||
if (item instanceof Submission) {
|
||||
if(!this.dryRun) {
|
||||
// @ts-ignore
|
||||
@@ -39,6 +48,7 @@ export class FlairAction extends Action {
|
||||
return {
|
||||
dryRun,
|
||||
success: true,
|
||||
result: flairSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +50,8 @@ export class UserNoteAction extends Action {
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
dryRun
|
||||
dryRun,
|
||||
result: `(${this.type}) ${renderedContent}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import dayjs, {Dayjs} from "dayjs";
|
||||
import {getLogger} from "./Utils/loggerFactory";
|
||||
import {Invokee, OperatorConfig} from "./Common/interfaces";
|
||||
import Bot from "./Bot";
|
||||
import {castArray} from "lodash";
|
||||
import LoggedError from "./Utils/LoggedError";
|
||||
|
||||
export class App {
|
||||
@@ -53,8 +52,11 @@ export class App {
|
||||
}
|
||||
|
||||
async onTerminate(reason = 'The application was shutdown') {
|
||||
for(const m of this.bots) {
|
||||
//await m.notificationManager.handle('runStateChanged', 'Application Shutdown', reason);
|
||||
for(const b of this.bots) {
|
||||
for(const m of b.subManagers) {
|
||||
await m.notificationManager.handle('runStateChanged', 'Application Shutdown', reason);
|
||||
}
|
||||
//await b.notificationManager.handle('runStateChanged', 'Application Shutdown', reason);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
330
src/Bot/index.ts
330
src/Bot/index.ts
@@ -3,7 +3,7 @@ import {Logger} from "winston";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {Duration} from "dayjs/plugin/duration";
|
||||
import EventEmitter from "events";
|
||||
import {BotInstanceConfig, Invokee, PAUSED, RUNNING, SYSTEM} from "../Common/interfaces";
|
||||
import {BotInstanceConfig, Invokee, PAUSED, RUNNING, STOPPED, SYSTEM, USER} from "../Common/interfaces";
|
||||
import {
|
||||
createRetryHandler,
|
||||
formatNumber,
|
||||
@@ -33,12 +33,15 @@ class Bot {
|
||||
excludeSubreddits: string[];
|
||||
subManagers: Manager[] = [];
|
||||
heartbeatInterval: number;
|
||||
nextHeartbeat?: Dayjs;
|
||||
nextHeartbeat: Dayjs = dayjs();
|
||||
heartBeating: boolean = false;
|
||||
|
||||
softLimit: number | string = 250;
|
||||
hardLimit: number | string = 50;
|
||||
nannyMode?: 'soft' | 'hard';
|
||||
nannyRunning: boolean = false;
|
||||
nextNannyCheck: Dayjs = dayjs().add(10, 'second');
|
||||
nannyRetryHandler: Function;
|
||||
nextExpiration: Dayjs = dayjs();
|
||||
botName?: string;
|
||||
botLink?: string;
|
||||
@@ -179,6 +182,7 @@ class Bot {
|
||||
}
|
||||
|
||||
const retryHandler = createRetryHandler({maxRequestRetry: 8, maxOtherRetry: 1}, this.logger);
|
||||
this.nannyRetryHandler = createRetryHandler({maxRequestRetry: 5, maxOtherRetry: 1}, this.logger);
|
||||
|
||||
const modStreamErrorListener = (name: string) => async (err: any) => {
|
||||
this.logger.error('Polling error occurred', err);
|
||||
@@ -322,7 +326,7 @@ class Bot {
|
||||
|
||||
async destroy(causedBy: Invokee) {
|
||||
this.logger.info('Stopping heartbeat and nanny processes, may take up to 5 seconds...');
|
||||
const processWait = Promise.all([pEvent(this.emitter, 'heartbeatStopped'), pEvent(this.emitter, 'nannyStopped')]);
|
||||
const processWait = pEvent(this.emitter, 'healthStopped');
|
||||
this.running = false;
|
||||
await processWait;
|
||||
for (const manager of this.subManagers) {
|
||||
@@ -361,144 +365,210 @@ class Bot {
|
||||
await this.runModStreams();
|
||||
|
||||
this.running = true;
|
||||
this.runApiNanny();
|
||||
this.nextNannyCheck = dayjs().add(10, 'second');
|
||||
this.nextHeartbeat = dayjs().add(this.heartbeatInterval, 'second');
|
||||
await this.healthLoop();
|
||||
}
|
||||
|
||||
async healthLoop() {
|
||||
while (this.running) {
|
||||
await sleep(5000);
|
||||
if (!this.running) {
|
||||
break;
|
||||
}
|
||||
if (dayjs().isSameOrAfter(this.nextNannyCheck)) {
|
||||
try {
|
||||
await this.runApiNanny();
|
||||
this.nextNannyCheck = dayjs().add(10, 'second');
|
||||
} catch (err) {
|
||||
this.logger.info('Delaying next nanny check for 1 minute due to emitted error');
|
||||
this.nextNannyCheck = dayjs().add(120, 'second');
|
||||
}
|
||||
}
|
||||
if(dayjs().isSameOrAfter(this.nextHeartbeat)) {
|
||||
try {
|
||||
await this.heartbeat();
|
||||
} catch (err) {
|
||||
this.logger.error(`Error occurred during heartbeat check: ${err.message}`);
|
||||
}
|
||||
this.nextHeartbeat = dayjs().add(this.heartbeatInterval, 'second');
|
||||
}
|
||||
}
|
||||
this.emitter.emit('healthStopped');
|
||||
}
|
||||
|
||||
async heartbeat() {
|
||||
const heartbeat = `HEARTBEAT -- API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ~${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion === undefined ? 'N/A' : this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`
|
||||
this.logger.info(heartbeat);
|
||||
for (const s of this.subManagers) {
|
||||
if(s.botState.state === STOPPED && s.botState.causedBy === USER) {
|
||||
this.logger.debug('Skipping config check/restart on heartbeat due to previously being stopped by user', {subreddit: s.displayLabel});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const newConfig = await s.parseConfiguration();
|
||||
if(newConfig || (s.queueState.state !== RUNNING && s.queueState.causedBy === SYSTEM))
|
||||
{
|
||||
await s.startQueue('system', {reason: newConfig ? 'Config updated on heartbeat triggered reload' : 'Heartbeat detected non-running queue'});
|
||||
}
|
||||
if(newConfig || (s.eventsState.state !== RUNNING && s.eventsState.causedBy === SYSTEM))
|
||||
{
|
||||
await s.startEvents('system', {reason: newConfig ? 'Config updated on heartbeat triggered reload' : 'Heartbeat detected non-running events'});
|
||||
}
|
||||
if(s.botState.state !== RUNNING && s.eventsState.state === RUNNING && s.queueState.state === RUNNING) {
|
||||
s.botState = {
|
||||
state: RUNNING,
|
||||
causedBy: 'system',
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info('Stopping event polling to prevent activity processing queue from backing up. Will be restarted when config update succeeds.')
|
||||
await s.stopEvents('system', {reason: 'Invalid config will cause events to pile up in queue. Will be restarted when config update succeeds (next heartbeat).'});
|
||||
if(!(err instanceof LoggedError)) {
|
||||
this.logger.error(err, {subreddit: s.displayLabel});
|
||||
}
|
||||
if(this.nextHeartbeat !== undefined) {
|
||||
this.logger.info(`Will retry parsing config on next heartbeat (in ${dayjs.duration(this.nextHeartbeat.diff(dayjs())).humanize()})`, {subreddit: s.displayLabel});
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.runModStreams(true);
|
||||
}
|
||||
|
||||
async runApiNanny() {
|
||||
try {
|
||||
mainLoop:
|
||||
while (this.running) {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
await sleep(5000);
|
||||
if (!this.running) {
|
||||
break mainLoop;
|
||||
}
|
||||
}
|
||||
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
const nowish = dayjs().add(10, 'second');
|
||||
if (nowish.isAfter(this.nextExpiration)) {
|
||||
// it's possible no api calls are being made because of a hard limit
|
||||
// need to make an api call to update this
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
const nowish = dayjs().add(10, 'second');
|
||||
if (nowish.isAfter(this.nextExpiration)) {
|
||||
// it's possible no api calls are being made because of a hard limit
|
||||
// need to make an api call to update this
|
||||
let shouldRetry = true;
|
||||
while (shouldRetry) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await this.client.getMe();
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
}
|
||||
const rollingSample = this.apiSample.slice(0, 7)
|
||||
rollingSample.unshift(this.client.ratelimitRemaining);
|
||||
this.apiSample = rollingSample;
|
||||
const diff = this.apiSample.reduceRight((acc: number[], curr, index) => {
|
||||
if (this.apiSample[index + 1] !== undefined) {
|
||||
const d = Math.abs(curr - this.apiSample[index + 1]);
|
||||
if (d === 0) {
|
||||
return [...acc, 0];
|
||||
}
|
||||
return [...acc, d / 10];
|
||||
shouldRetry = false;
|
||||
} catch (err) {
|
||||
shouldRetry = await this.nannyRetryHandler(err);
|
||||
if (!shouldRetry) {
|
||||
throw err;
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
this.apiRollingAvg = diff.reduce((acc, curr) => acc + curr, 0) / diff.length; // api requests per second
|
||||
this.depletedInSecs = this.client.ratelimitRemaining / this.apiRollingAvg; // number of seconds until current remaining limit is 0
|
||||
this.apiEstDepletion = dayjs.duration({seconds: this.depletedInSecs});
|
||||
this.logger.debug(`API Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`);
|
||||
|
||||
|
||||
let hardLimitHit = false;
|
||||
if (typeof this.hardLimit === 'string') {
|
||||
const hardDur = parseDuration(this.hardLimit);
|
||||
hardLimitHit = hardDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
hardLimitHit = this.hardLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if (hardLimitHit) {
|
||||
if (this.nannyMode === 'hard') {
|
||||
continue;
|
||||
}
|
||||
this.logger.info(`Detected HARD LIMIT of ${this.hardLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${this.apiRollingAvg}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`All subreddit event polling has been paused`, {leaf: 'Api Nanny'});
|
||||
|
||||
for (const m of this.subManagers) {
|
||||
m.pauseEvents('system');
|
||||
m.notificationManager.handle('runStateChanged', 'Hard Limit Triggered', `Hard Limit of ${this.hardLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit event polling has been paused.`, 'system', 'warn');
|
||||
}
|
||||
|
||||
this.nannyMode = 'hard';
|
||||
continue;
|
||||
}
|
||||
|
||||
let softLimitHit = false;
|
||||
if (typeof this.softLimit === 'string') {
|
||||
const softDur = parseDuration(this.softLimit);
|
||||
softLimitHit = softDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
softLimitHit = this.softLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if (softLimitHit) {
|
||||
if (this.nannyMode === 'soft') {
|
||||
continue;
|
||||
}
|
||||
this.logger.info(`Detected SOFT LIMIT of ${this.softLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info('Trying to detect heavy usage subreddits...', {leaf: 'Api Nanny'});
|
||||
let threshold = 0.5;
|
||||
let offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
if (offenders.length === 0) {
|
||||
threshold = 0.25;
|
||||
// reduce threshold
|
||||
offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
this.logger.info(`Slowing subreddits using >- ${threshold}req/s:`, {leaf: 'Api Nanny'});
|
||||
for (const m of offenders) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`Couldn't detect specific offenders, slowing all...`, {leaf: 'Api Nanny'});
|
||||
for (const m of this.subManagers) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
}
|
||||
}
|
||||
this.nannyMode = 'soft';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.nannyMode !== undefined) {
|
||||
this.logger.info('Turning off due to better conditions...', {leaf: 'Api Nanny'});
|
||||
for (const m of this.subManagers) {
|
||||
if (m.delayBy !== undefined) {
|
||||
m.delayBy = undefined;
|
||||
m.notificationManager.handle('runStateChanged', 'Normal Processing Resumed', 'Slow Mode has been turned off due to better API conditions', 'system');
|
||||
}
|
||||
if (m.queueState.state === PAUSED && m.queueState.causedBy === SYSTEM) {
|
||||
m.startQueue('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
if (m.eventsState.state === PAUSED && m.eventsState.causedBy === SYSTEM) {
|
||||
await m.startEvents('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
}
|
||||
this.nannyMode = undefined;
|
||||
}
|
||||
}
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
}
|
||||
const rollingSample = this.apiSample.slice(0, 7)
|
||||
rollingSample.unshift(this.client.ratelimitRemaining);
|
||||
this.apiSample = rollingSample;
|
||||
const diff = this.apiSample.reduceRight((acc: number[], curr, index) => {
|
||||
if (this.apiSample[index + 1] !== undefined) {
|
||||
const d = Math.abs(curr - this.apiSample[index + 1]);
|
||||
if (d === 0) {
|
||||
return [...acc, 0];
|
||||
}
|
||||
return [...acc, d / 10];
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
this.apiRollingAvg = diff.reduce((acc, curr) => acc + curr, 0) / diff.length; // api requests per second
|
||||
this.depletedInSecs = this.client.ratelimitRemaining / this.apiRollingAvg; // number of seconds until current remaining limit is 0
|
||||
this.apiEstDepletion = dayjs.duration({seconds: this.depletedInSecs});
|
||||
this.logger.debug(`API Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`);
|
||||
|
||||
|
||||
let hardLimitHit = false;
|
||||
if (typeof this.hardLimit === 'string') {
|
||||
const hardDur = parseDuration(this.hardLimit);
|
||||
hardLimitHit = hardDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
hardLimitHit = this.hardLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if (hardLimitHit) {
|
||||
if (this.nannyMode === 'hard') {
|
||||
return;
|
||||
}
|
||||
this.logger.info(`Detected HARD LIMIT of ${this.hardLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${this.apiRollingAvg}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`All subreddit event polling has been paused`, {leaf: 'Api Nanny'});
|
||||
|
||||
for (const m of this.subManagers) {
|
||||
m.pauseEvents('system');
|
||||
m.notificationManager.handle('runStateChanged', 'Hard Limit Triggered', `Hard Limit of ${this.hardLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit event polling has been paused.`, 'system', 'warn');
|
||||
}
|
||||
|
||||
this.nannyMode = 'hard';
|
||||
return;
|
||||
}
|
||||
|
||||
let softLimitHit = false;
|
||||
if (typeof this.softLimit === 'string') {
|
||||
const softDur = parseDuration(this.softLimit);
|
||||
softLimitHit = softDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
softLimitHit = this.softLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if (softLimitHit) {
|
||||
if (this.nannyMode === 'soft') {
|
||||
return;
|
||||
}
|
||||
this.logger.info(`Detected SOFT LIMIT of ${this.softLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info('Trying to detect heavy usage subreddits...', {leaf: 'Api Nanny'});
|
||||
let threshold = 0.5;
|
||||
let offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
if (offenders.length === 0) {
|
||||
threshold = 0.25;
|
||||
// reduce threshold
|
||||
offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
this.logger.info(`Slowing subreddits using >- ${threshold}req/s:`, {leaf: 'Api Nanny'});
|
||||
for (const m of offenders) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`Couldn't detect specific offenders, slowing all...`, {leaf: 'Api Nanny'});
|
||||
for (const m of this.subManagers) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
}
|
||||
}
|
||||
this.nannyMode = 'soft';
|
||||
return
|
||||
}
|
||||
|
||||
if (this.nannyMode !== undefined) {
|
||||
this.logger.info('Turning off due to better conditions...', {leaf: 'Api Nanny'});
|
||||
for (const m of this.subManagers) {
|
||||
if (m.delayBy !== undefined) {
|
||||
m.delayBy = undefined;
|
||||
m.notificationManager.handle('runStateChanged', 'Normal Processing Resumed', 'Slow Mode has been turned off due to better API conditions', 'system');
|
||||
}
|
||||
if (m.queueState.state === PAUSED && m.queueState.causedBy === SYSTEM) {
|
||||
m.startQueue('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
if (m.eventsState.state === PAUSED && m.eventsState.causedBy === SYSTEM) {
|
||||
await m.startEvents('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
}
|
||||
this.nannyMode = undefined;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error('Error occurred during nanny loop', err);
|
||||
this.logger.error(`Error occurred during nanny loop: ${err.message}`);
|
||||
throw err;
|
||||
} finally {
|
||||
this.logger.info('Nanny stopped');
|
||||
this.emitter.emit('nannyStopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export const cacheOptDefaults = {ttl: 60, max: 500, checkPeriod: 600};
|
||||
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300, submissionTTL: 60, commentTTL: 60, filterCriteriaTTL: 60};
|
||||
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300, submissionTTL: 60, commentTTL: 60, filterCriteriaTTL: 60, subredditTTL: 600};
|
||||
|
||||
@@ -433,6 +433,16 @@ export interface TTLConfig {
|
||||
* @default 60
|
||||
* */
|
||||
commentTTL?: number | boolean;
|
||||
/**
|
||||
* Amount of time, in seconds, a subreddit (attributes) should be cached
|
||||
*
|
||||
* * If `0` or `true` will cache indefinitely (not recommended)
|
||||
* * If `false` will not cache
|
||||
*
|
||||
* @examples [600]
|
||||
* @default 600
|
||||
* */
|
||||
subredditTTL?: number | boolean;
|
||||
/**
|
||||
* Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)
|
||||
*
|
||||
@@ -456,6 +466,32 @@ export interface CacheConfig extends TTLConfig {
|
||||
* To specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`
|
||||
* */
|
||||
provider?: CacheProvider | CacheOptions
|
||||
|
||||
/**
|
||||
* The **maximum** number of Events that the cache should store triggered result summaries for
|
||||
*
|
||||
* These summaries are viewable through the Web UI.
|
||||
*
|
||||
* The value specified by a subreddit cannot be larger than the value set by the Operator for the global/bot config (if set)
|
||||
*
|
||||
* @default 25
|
||||
* @example [25]
|
||||
* */
|
||||
actionedEventsMax?: number
|
||||
}
|
||||
|
||||
export interface OperatorCacheConfig extends CacheConfig {
|
||||
/**
|
||||
* The **default** number of Events that the cache will store triggered result summaries for
|
||||
*
|
||||
* These summaries are viewable through the Web UI.
|
||||
*
|
||||
* The value specified cannot be larger than `actionedEventsMax` for the global/bot config (if set)
|
||||
*
|
||||
* @default 25
|
||||
* @example [25]
|
||||
* */
|
||||
actionedEventsDefault?: number
|
||||
}
|
||||
|
||||
export interface Footer {
|
||||
@@ -639,6 +675,8 @@ export interface ActivityState {
|
||||
stickied?: boolean
|
||||
distinguished?: boolean
|
||||
approved?: boolean
|
||||
score?: CompareValue
|
||||
reports?: CompareValue
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -677,6 +715,41 @@ export interface CommentState extends ActivityState {
|
||||
submissionState?: SubmissionState[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Different attributes a `Subreddit` can be in. Only include a property if you want to check it.
|
||||
* @examples [{"over18": true}]
|
||||
* */
|
||||
export interface SubredditState {
|
||||
/**
|
||||
* Is subreddit quarantined?
|
||||
* */
|
||||
quarantine?: boolean
|
||||
/**
|
||||
* Is subreddit NSFW/over 18?
|
||||
*
|
||||
* **Note**: This is **mod-controlled flag** so it is up to the mods of the subreddit to correctly mark their subreddit as NSFW
|
||||
* */
|
||||
over18?: boolean
|
||||
/**
|
||||
* The name the subreddit.
|
||||
*
|
||||
* Can be a normal string (will check case-insensitive) or a regular expression
|
||||
*
|
||||
* EX `["mealtimevideos", "/onlyfans*\/i"]`
|
||||
*
|
||||
* @examples ["mealtimevideos", "/onlyfans*\/i"]
|
||||
* */
|
||||
name?: string | RegExp
|
||||
/**
|
||||
* A friendly description of what this State is trying to parse
|
||||
* */
|
||||
stateDescription?: string
|
||||
}
|
||||
|
||||
export interface StrongSubredditState extends SubredditState {
|
||||
name?: RegExp
|
||||
}
|
||||
|
||||
export type TypedActivityStates = SubmissionState[] | CommentState[];
|
||||
|
||||
export interface DomainInfo {
|
||||
@@ -729,8 +802,11 @@ export type StrongCache = {
|
||||
wikiTTL: number | boolean,
|
||||
submissionTTL: number | boolean,
|
||||
commentTTL: number | boolean,
|
||||
subredditTTL: number | boolean,
|
||||
filterCriteriaTTL: number | boolean,
|
||||
provider: CacheOptions
|
||||
actionedEventsMax?: number,
|
||||
actionedEventsDefault: number,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1091,7 +1167,7 @@ export interface BotInstanceJsonConfig {
|
||||
*
|
||||
* Every setting not specified will default to what is specified by the global operator caching config
|
||||
* */
|
||||
caching?: CacheConfig
|
||||
caching?: OperatorCacheConfig
|
||||
/**
|
||||
* Settings related to managing heavy API usage.
|
||||
* */
|
||||
@@ -1204,7 +1280,7 @@ export interface OperatorJsonConfig {
|
||||
*
|
||||
* These settings will be used by each bot, and subreddit, that does not specify their own
|
||||
* */
|
||||
caching?: CacheConfig
|
||||
caching?: OperatorCacheConfig
|
||||
|
||||
bots?: BotInstanceJsonConfig[]
|
||||
|
||||
@@ -1455,6 +1531,7 @@ export interface ActionedEvent {
|
||||
timestamp: number
|
||||
check: string
|
||||
ruleSummary: string,
|
||||
subreddit: string,
|
||||
ruleResults: RuleResult[]
|
||||
actionResults: ActionResult[]
|
||||
}
|
||||
@@ -1463,3 +1540,10 @@ export interface UserResultCache {
|
||||
result: boolean,
|
||||
ruleResults: RuleResult[]
|
||||
}
|
||||
|
||||
export type RedditEntityType = 'user' | 'subreddit';
|
||||
|
||||
export interface RedditEntity {
|
||||
name: string
|
||||
type: RedditEntityType
|
||||
}
|
||||
|
||||
@@ -348,9 +348,9 @@ export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
|
||||
return removeUndefinedKeys(data) as OperatorJsonConfig;
|
||||
}
|
||||
|
||||
const parseListFromEnv = (val: string|undefined) => {
|
||||
const parseListFromEnv = (val: string | undefined) => {
|
||||
let listVals: undefined | string[];
|
||||
if(val === undefined) {
|
||||
if (val === undefined) {
|
||||
return listVals;
|
||||
}
|
||||
const trimmedVal = val.trim();
|
||||
@@ -401,7 +401,7 @@ export const parseDefaultBotInstanceFromEnv = (): BotInstanceJsonConfig => {
|
||||
export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
|
||||
const data = {
|
||||
mode: process.env.MODE !== undefined ? process.env.MODE as ('all' | 'server' | 'client') : undefined,
|
||||
operator: {
|
||||
operator: {
|
||||
name: parseListFromEnv(process.env.OPERATOR),
|
||||
display: process.env.OPERATOR_DISPLAY
|
||||
},
|
||||
@@ -504,12 +504,12 @@ export const parseOperatorConfigFromSources = async (args: any): Promise<Operato
|
||||
arrayMerge: overwriteMerge,
|
||||
}) as BotInstanceJsonConfig;
|
||||
|
||||
if(configFromFile.caching !== undefined) {
|
||||
if (configFromFile.caching !== undefined) {
|
||||
defaultBotInstance.caching = configFromFile.caching;
|
||||
}
|
||||
|
||||
let botInstances = [];
|
||||
if(botInstancesFromFile.length === 0) {
|
||||
if (botInstancesFromFile.length === 0) {
|
||||
botInstances = [defaultBotInstance];
|
||||
} else {
|
||||
botInstances = botInstancesFromFile.map(x => merge.all([defaultBotInstance, x], {arrayMerge: overwriteMerge}));
|
||||
@@ -555,20 +555,29 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
|
||||
let cache: StrongCache;
|
||||
let defaultProvider: CacheOptions;
|
||||
let opActionedEventsMax: number | undefined;
|
||||
let opActionedEventsDefault: number = 25;
|
||||
|
||||
if(opCache === undefined) {
|
||||
defaultProvider = {
|
||||
if (opCache === undefined) {
|
||||
defaultProvider = {
|
||||
store: 'memory',
|
||||
...cacheOptDefaults
|
||||
};
|
||||
cache = {
|
||||
...cacheTTLDefaults,
|
||||
provider: defaultProvider
|
||||
provider: defaultProvider,
|
||||
actionedEventsDefault: opActionedEventsDefault,
|
||||
};
|
||||
|
||||
} else {
|
||||
const {provider, ...restConfig} = opCache;
|
||||
if(typeof provider === 'string') {
|
||||
const {provider, actionedEventsMax, actionedEventsDefault = opActionedEventsDefault, ...restConfig} = opCache;
|
||||
|
||||
if (actionedEventsMax !== undefined && actionedEventsMax !== null) {
|
||||
opActionedEventsMax = actionedEventsMax;
|
||||
opActionedEventsDefault = Math.min(actionedEventsDefault, actionedEventsMax);
|
||||
}
|
||||
|
||||
if (typeof provider === 'string') {
|
||||
defaultProvider = {
|
||||
store: provider as CacheProvider,
|
||||
...cacheOptDefaults
|
||||
@@ -584,112 +593,132 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
cache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
actionedEventsMax: opActionedEventsMax,
|
||||
actionedEventsDefault: opActionedEventsDefault,
|
||||
provider: defaultProvider,
|
||||
}
|
||||
}
|
||||
|
||||
let hydratedBots: BotInstanceConfig[] = bots.map(x => {
|
||||
const {
|
||||
name: botName,
|
||||
polling: {
|
||||
sharedMod = false,
|
||||
limit = 100,
|
||||
interval = 30,
|
||||
} = {},
|
||||
queue: {
|
||||
maxWorkers = 1,
|
||||
} = {},
|
||||
caching,
|
||||
nanny: {
|
||||
softLimit = 250,
|
||||
hardLimit = 50
|
||||
} = {},
|
||||
snoowrap = {},
|
||||
credentials: {
|
||||
clientId: ci,
|
||||
clientSecret: cs,
|
||||
...restCred
|
||||
} = {},
|
||||
subreddits: {
|
||||
names = [],
|
||||
exclude = [],
|
||||
wikiConfig = 'botconfig/contextbot',
|
||||
dryRun,
|
||||
heartbeatInterval = 300,
|
||||
} = {},
|
||||
} = x;
|
||||
let hydratedBots: BotInstanceConfig[] = bots.map(x => {
|
||||
const {
|
||||
name: botName,
|
||||
polling: {
|
||||
sharedMod = false,
|
||||
limit = 100,
|
||||
interval = 30,
|
||||
} = {},
|
||||
queue: {
|
||||
maxWorkers = 1,
|
||||
} = {},
|
||||
caching,
|
||||
nanny: {
|
||||
softLimit = 250,
|
||||
hardLimit = 50
|
||||
} = {},
|
||||
snoowrap = {},
|
||||
credentials: {
|
||||
clientId: ci,
|
||||
clientSecret: cs,
|
||||
...restCred
|
||||
} = {},
|
||||
subreddits: {
|
||||
names = [],
|
||||
exclude = [],
|
||||
wikiConfig = 'botconfig/contextbot',
|
||||
dryRun,
|
||||
heartbeatInterval = 300,
|
||||
} = {},
|
||||
} = x;
|
||||
|
||||
|
||||
let botCache: StrongCache;
|
||||
let botCache: StrongCache;
|
||||
let botActionedEventsDefault: number;
|
||||
|
||||
if (caching === undefined) {
|
||||
|
||||
if(caching === undefined) {
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
provider: {
|
||||
store: 'memory',
|
||||
...cacheOptDefaults
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const {provider, ...restConfig} = caching;
|
||||
if (typeof provider === 'string') {
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
actionedEventsDefault: opActionedEventsDefault,
|
||||
actionedEventsMax: opActionedEventsMax,
|
||||
provider: {
|
||||
store: provider as CacheProvider,
|
||||
store: 'memory',
|
||||
...cacheOptDefaults
|
||||
}
|
||||
}
|
||||
};
|
||||
} else {
|
||||
const {ttl = 60, max = 500, store = 'memory', ...rest} = provider || {};
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
provider: {
|
||||
store,
|
||||
...cacheOptDefaults,
|
||||
...rest,
|
||||
},
|
||||
const {
|
||||
provider,
|
||||
actionedEventsMax = opActionedEventsMax,
|
||||
actionedEventsDefault = opActionedEventsDefault,
|
||||
...restConfig
|
||||
} = caching;
|
||||
|
||||
botActionedEventsDefault = actionedEventsDefault;
|
||||
if(actionedEventsMax !== undefined) {
|
||||
botActionedEventsDefault = Math.min(actionedEventsDefault, actionedEventsMax);
|
||||
}
|
||||
|
||||
if (typeof provider === 'string') {
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
actionedEventsDefault: botActionedEventsDefault,
|
||||
provider: {
|
||||
store: provider as CacheProvider,
|
||||
...cacheOptDefaults
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const {ttl = 60, max = 500, store = 'memory', ...rest} = provider || {};
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
actionedEventsDefault: botActionedEventsDefault,
|
||||
actionedEventsMax,
|
||||
provider: {
|
||||
store,
|
||||
...cacheOptDefaults,
|
||||
...rest,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const botCreds = {
|
||||
const botCreds = {
|
||||
clientId: (ci as string),
|
||||
clientSecret: (cs as string),
|
||||
...restCred,
|
||||
clientSecret: (cs as string),
|
||||
...restCred,
|
||||
};
|
||||
if (botCache.provider.prefix === undefined || botCache.provider.prefix === defaultProvider.prefix) {
|
||||
// need to provide unique prefix to bot
|
||||
botCache.provider.prefix = buildCachePrefix([botCache.provider.prefix, 'bot', (botName || objectHash.sha1(botCreds))]);
|
||||
}
|
||||
|
||||
return {
|
||||
name: botName,
|
||||
snoowrap,
|
||||
subreddits: {
|
||||
names,
|
||||
exclude,
|
||||
wikiConfig,
|
||||
heartbeatInterval,
|
||||
dryRun,
|
||||
},
|
||||
credentials: botCreds,
|
||||
caching: botCache,
|
||||
polling: {
|
||||
sharedMod,
|
||||
limit,
|
||||
interval,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers,
|
||||
},
|
||||
nanny: {
|
||||
softLimit,
|
||||
hardLimit
|
||||
return {
|
||||
name: botName,
|
||||
snoowrap,
|
||||
subreddits: {
|
||||
names,
|
||||
exclude,
|
||||
wikiConfig,
|
||||
heartbeatInterval,
|
||||
dryRun,
|
||||
},
|
||||
credentials: botCreds,
|
||||
caching: botCache,
|
||||
polling: {
|
||||
sharedMod,
|
||||
limit,
|
||||
interval,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers,
|
||||
},
|
||||
nanny: {
|
||||
softLimit,
|
||||
hardLimit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
|
||||
@@ -53,8 +53,6 @@ export interface AttributionCriteria {
|
||||
/**
|
||||
* 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`
|
||||
@@ -98,7 +96,7 @@ export interface AttributionCriteria {
|
||||
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`
|
||||
* 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)
|
||||
@@ -240,7 +238,7 @@ export class AttributionRule extends Rule {
|
||||
domainType = 'self';
|
||||
}
|
||||
|
||||
if(realDomains.length === 0 && aggregateOn.length !== 0) {
|
||||
if(aggregateOn.length !== 0) {
|
||||
if(domainType === 'media' && !aggregateOn.includes('media')) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
import {ActivityWindowType, CompareValueOrPercent, ThresholdCriteria} from "../Common/interfaces";
|
||||
import {ActivityWindowType, CompareValueOrPercent, SubredditState, ThresholdCriteria} from "../Common/interfaces";
|
||||
import {Rule, RuleJSONConfig, RuleOptions, RuleResult} from "./index";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {getAuthorActivities} from "../Utils/SnoowrapUtils";
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
formatNumber, getActivitySubredditName, isSubmission,
|
||||
parseGenericValueOrPercentComparison, parseSubredditName,
|
||||
PASS,
|
||||
percentFromString
|
||||
percentFromString, toStrongSubredditState
|
||||
} from "../util";
|
||||
import {Comment} from "snoowrap";
|
||||
|
||||
export interface CommentThresholdCriteria extends ThresholdCriteria {
|
||||
/**
|
||||
@@ -24,42 +25,56 @@ export interface CommentThresholdCriteria extends ThresholdCriteria {
|
||||
asOp?: boolean
|
||||
}
|
||||
/**
|
||||
* If both `submission` and `comment` are defined then criteria will only trigger if BOTH thresholds are met
|
||||
* Criteria will only trigger if ALL present thresholds (comment, submission, total) are met
|
||||
* */
|
||||
export interface HistoryCriteria {
|
||||
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare submissions against
|
||||
* A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) 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
|
||||
* * EX `> 100` => greater than 100 filtered submissions
|
||||
* * EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
|
||||
* */
|
||||
submission?: CompareValueOrPercent
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare comments against
|
||||
* A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) 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
|
||||
* * EX `<= 75%` => comments are equal to or less than 75% of unfiltered 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**
|
||||
* * EX `> 100 OP` => greater than 100 filtered comments as OP
|
||||
* * EX `<= 25% as OP` => **Filtered** comments as OP were less then or equal to 25% of **unfiltered Comments**
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
|
||||
* */
|
||||
comment?: CompareValueOrPercent
|
||||
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`) activities against
|
||||
*
|
||||
* **Note:** This is only useful if using `include` or `exclude` otherwise percent will always be 100% and total === activityTotal
|
||||
*
|
||||
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign] [OP]`
|
||||
*
|
||||
* * EX `> 100` => greater than 100 filtered activities
|
||||
* * EX `<= 75%` => filtered activities are equal to or less than 75% of all Activities
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
|
||||
* */
|
||||
total?: CompareValueOrPercent
|
||||
|
||||
window: ActivityWindowType
|
||||
|
||||
/**
|
||||
* The minimum number of activities that must exist from the `window` results for this criteria to run
|
||||
* The minimum number of **filtered** activities that must exist from the `window` results for this criteria to run
|
||||
* @default 5
|
||||
* */
|
||||
minActivityCount?: number
|
||||
@@ -69,8 +84,9 @@ export interface HistoryCriteria {
|
||||
export class HistoryRule extends Rule {
|
||||
criteria: HistoryCriteria[];
|
||||
condition: 'AND' | 'OR';
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
include: (string | SubredditState)[];
|
||||
exclude: (string | SubredditState)[];
|
||||
activityFilterFunc: (x: Submission|Comment) => Promise<boolean> = async (x) => true;
|
||||
|
||||
constructor(options: HistoryOptions) {
|
||||
super(options);
|
||||
@@ -86,8 +102,41 @@ export class HistoryRule extends Rule {
|
||||
if (this.criteria.length === 0) {
|
||||
throw new Error('Must provide at least one HistoryCriteria');
|
||||
}
|
||||
this.include = include.map(x => parseSubredditName(x).toLowerCase());
|
||||
this.exclude = exclude.map(x => parseSubredditName(x).toLowerCase());
|
||||
|
||||
this.include = include;
|
||||
this.exclude = exclude;
|
||||
|
||||
if(this.include.length > 0) {
|
||||
const subStates = include.map((x) => {
|
||||
if(typeof x === 'string') {
|
||||
return toStrongSubredditState({name: x, stateDescription: x}, {defaultFlags: 'i', generateDescription: true});
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
});
|
||||
this.activityFilterFunc = async (x: Submission|Comment) => {
|
||||
for(const ss of subStates) {
|
||||
if(await this.resources.testSubredditCriteria(x, ss)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
} else if(this.exclude.length > 0) {
|
||||
const subStates = exclude.map((x) => {
|
||||
if(typeof x === 'string') {
|
||||
return toStrongSubredditState({name: x, stateDescription: x}, {defaultFlags: 'i', generateDescription: true});
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
});
|
||||
this.activityFilterFunc = async (x: Submission|Comment) => {
|
||||
for(const ss of subStates) {
|
||||
if(await this.resources.testSubredditCriteria(x, ss)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getKind(): string {
|
||||
@@ -108,19 +157,17 @@ export class HistoryRule extends Rule {
|
||||
|
||||
for (const criteria of this.criteria) {
|
||||
|
||||
const {comment, window, submission, minActivityCount = 5} = criteria;
|
||||
const {comment, window, submission, total, minActivityCount = 5} = criteria;
|
||||
|
||||
let activities = await this.resources.getAuthorActivities(item.author, {window: window});
|
||||
activities = activities.filter(act => {
|
||||
if (this.include.length > 0) {
|
||||
return this.include.some(x => x === getActivitySubredditName(act).toLowerCase());
|
||||
} else if (this.exclude.length > 0) {
|
||||
return !this.exclude.some(x => x === getActivitySubredditName(act).toLowerCase())
|
||||
const filteredActivities = [];
|
||||
for(const a of activities) {
|
||||
if(await this.activityFilterFunc(a)) {
|
||||
filteredActivities.push(a);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
if (activities.length < minActivityCount) {
|
||||
if (filteredActivities.length < minActivityCount) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -135,6 +182,24 @@ export class HistoryRule extends Rule {
|
||||
}
|
||||
return a;
|
||||
},{submissionTotal: 0, commentTotal: 0, opTotal: 0});
|
||||
let fSubmissionTotal = submissionTotal;
|
||||
let fCommentTotal = commentTotal;
|
||||
let fOpTotal = opTotal;
|
||||
if(activities.length !== filteredActivities.length) {
|
||||
const filteredCounts = filteredActivities.reduce((acc, act) => {
|
||||
if(asSubmission(act)) {
|
||||
return {...acc, submissionTotal: acc.submissionTotal + 1};
|
||||
}
|
||||
let a = {...acc, commentTotal: acc.commentTotal + 1};
|
||||
if(act.is_submitter) {
|
||||
a.opTotal = a.opTotal + 1;
|
||||
}
|
||||
return a;
|
||||
},{submissionTotal: 0, commentTotal: 0, opTotal: 0});
|
||||
fSubmissionTotal = filteredCounts.submissionTotal;
|
||||
fCommentTotal = filteredCounts.commentTotal;
|
||||
fOpTotal = filteredCounts.opTotal;
|
||||
}
|
||||
|
||||
let commentTrigger = undefined;
|
||||
if(comment !== undefined) {
|
||||
@@ -143,15 +208,15 @@ export class HistoryRule extends Rule {
|
||||
if(isPercent) {
|
||||
const per = value / 100;
|
||||
if(asOp) {
|
||||
commentTrigger = comparisonTextOp(opTotal / commentTotal, operator, per);
|
||||
commentTrigger = comparisonTextOp(fOpTotal / commentTotal, operator, per);
|
||||
} else {
|
||||
commentTrigger = comparisonTextOp(commentTotal / activityTotal, operator, per);
|
||||
commentTrigger = comparisonTextOp(fCommentTotal / activityTotal, operator, per);
|
||||
}
|
||||
} else {
|
||||
if(asOp) {
|
||||
commentTrigger = comparisonTextOp(opTotal, operator, value);
|
||||
commentTrigger = comparisonTextOp(fOpTotal, operator, value);
|
||||
} else {
|
||||
commentTrigger = comparisonTextOp(commentTotal, operator, value);
|
||||
commentTrigger = comparisonTextOp(fCommentTotal, operator, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,9 +226,20 @@ export class HistoryRule extends Rule {
|
||||
const {operator, value, isPercent} = parseGenericValueOrPercentComparison(submission);
|
||||
if(isPercent) {
|
||||
const per = value / 100;
|
||||
submissionTrigger = comparisonTextOp(submissionTotal / activityTotal, operator, per);
|
||||
submissionTrigger = comparisonTextOp(fSubmissionTotal / activityTotal, operator, per);
|
||||
} else {
|
||||
submissionTrigger = comparisonTextOp(submissionTotal, operator, value);
|
||||
submissionTrigger = comparisonTextOp(fSubmissionTotal, operator, value);
|
||||
}
|
||||
}
|
||||
|
||||
let totalTrigger = undefined;
|
||||
if(total !== undefined) {
|
||||
const {operator, value, isPercent} = parseGenericValueOrPercentComparison(total);
|
||||
if(isPercent) {
|
||||
const per = value / 100;
|
||||
totalTrigger = comparisonTextOp(filteredActivities.length / activityTotal, operator, per);
|
||||
} else {
|
||||
totalTrigger = comparisonTextOp(filteredActivities.length, operator, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,12 +252,14 @@ export class HistoryRule extends Rule {
|
||||
criteria,
|
||||
activityTotal,
|
||||
activityTotalWindow,
|
||||
submissionTotal,
|
||||
commentTotal,
|
||||
opTotal,
|
||||
submissionTotal: fSubmissionTotal,
|
||||
commentTotal: fCommentTotal,
|
||||
opTotal: fOpTotal,
|
||||
filteredTotal: filteredActivities.length,
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true)
|
||||
totalTrigger,
|
||||
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,36 +302,50 @@ export class HistoryRule extends Rule {
|
||||
activityTotalWindow,
|
||||
submissionTotal,
|
||||
commentTotal,
|
||||
filteredTotal,
|
||||
opTotal,
|
||||
criteria: {
|
||||
comment,
|
||||
submission,
|
||||
total,
|
||||
window,
|
||||
},
|
||||
criteria,
|
||||
triggered,
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
} = results;
|
||||
|
||||
const data: any = {
|
||||
activityTotal,
|
||||
submissionTotal,
|
||||
commentTotal,
|
||||
filteredTotal,
|
||||
opTotal,
|
||||
commentPercent: formatNumber((commentTotal/activityTotal)*100),
|
||||
submissionPercent: formatNumber((submissionTotal/activityTotal)*100),
|
||||
opPercent: formatNumber((opTotal/commentTotal)*100),
|
||||
filteredPercent: formatNumber((filteredTotal/activityTotal)*100),
|
||||
criteria,
|
||||
window: typeof window === 'number' || activityTotal === 0 ? `${activityTotal} Items` : activityTotalWindow.humanize(true),
|
||||
triggered,
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
};
|
||||
|
||||
let thresholdSummary = [];
|
||||
let totalSummary;
|
||||
let submissionSummary;
|
||||
let commentSummary;
|
||||
if(total !== undefined) {
|
||||
const {operator, value, isPercent, displayText} = parseGenericValueOrPercentComparison(total);
|
||||
const suffix = !isPercent ? 'Items' : `(${formatNumber((filteredTotal/activityTotal)*100)}%) of ${activityTotal} Total`;
|
||||
totalSummary = `${includePassFailSymbols ? `${submissionTrigger ? PASS : FAIL} ` : ''}Filtered Activities (${filteredTotal}) were${totalTrigger ? '' : ' not'} ${displayText} ${suffix}`;
|
||||
data.totalSummary = totalSummary;
|
||||
thresholdSummary.push(totalSummary);
|
||||
}
|
||||
if(submission !== undefined) {
|
||||
const {operator, value, isPercent, displayText} = parseGenericValueOrPercentComparison(submission);
|
||||
const suffix = !isPercent ? 'Items' : `(${formatNumber((submissionTotal/activityTotal)*100)}%) of ${activityTotal} Total`;
|
||||
@@ -299,21 +391,45 @@ interface HistoryConfig {
|
||||
condition?: 'AND' | 'OR'
|
||||
|
||||
/**
|
||||
* Only include Submissions from this list of Subreddits (by name, case-insensitive)
|
||||
* If present, activities will be counted only if they are found in this list of Subreddits.
|
||||
*
|
||||
* EX `["mealtimevideos","askscience"]`
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* Each value in the list can be either:
|
||||
*
|
||||
* * string (name of subreddit)
|
||||
* * regular expression to run on the subreddit name
|
||||
* * `SubredditState`
|
||||
*
|
||||
* EX `["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]`
|
||||
*
|
||||
* **Note:** This affects **post-window retrieval** activities. So that:
|
||||
*
|
||||
* * `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering
|
||||
* * all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**
|
||||
* * -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`
|
||||
*
|
||||
* @examples [["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]]
|
||||
* */
|
||||
include?: string[],
|
||||
include?: (string | SubredditState)[],
|
||||
/**
|
||||
* Do not include Submissions from this list of Subreddits (by name, case-insensitive)
|
||||
* If present, activities will be counted only if they are **NOT** found in this list of Subreddits
|
||||
*
|
||||
* EX `["mealtimevideos","askscience"]`
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* Each value in the list can be either:
|
||||
*
|
||||
* * string (name of subreddit)
|
||||
* * regular expression to run on the subreddit name
|
||||
* * `SubredditState`
|
||||
*
|
||||
* EX `["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]`
|
||||
*
|
||||
* **Note:** This affects **post-window retrieval** activities. So that:
|
||||
*
|
||||
* * `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering
|
||||
* * all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**
|
||||
* * -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`
|
||||
*
|
||||
* @examples [["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]]
|
||||
* */
|
||||
exclude?: string[],
|
||||
exclude?: (string | SubredditState)[],
|
||||
}
|
||||
|
||||
export interface HistoryOptions extends HistoryConfig, RuleOptions {
|
||||
|
||||
@@ -3,24 +3,25 @@ import {Comment, VoteableContent} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {
|
||||
activityWindowText, asSubmission,
|
||||
comparisonTextOp, FAIL, formatNumber, getActivitySubredditName, isSubmission,
|
||||
parseGenericValueOrPercentComparison, parseSubredditName,
|
||||
comparisonTextOp, FAIL, formatNumber, getActivitySubredditName, isSubmission, objectToStringSummary,
|
||||
parseGenericValueOrPercentComparison, parseStringToRegex, parseSubredditName,
|
||||
parseUsableLinkIdentifier,
|
||||
PASS
|
||||
PASS, toStrongSubredditState
|
||||
} from "../util";
|
||||
import {
|
||||
ActivityWindow,
|
||||
ActivityWindowCriteria,
|
||||
ActivityWindowType,
|
||||
ReferenceSubmission,
|
||||
SubredditCriteria
|
||||
ActivityWindowType, CommentState,
|
||||
ReferenceSubmission, StrongSubredditState, SubmissionState,
|
||||
SubredditCriteria, SubredditState
|
||||
} from "../Common/interfaces";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
|
||||
const parseLink = parseUsableLinkIdentifier();
|
||||
|
||||
export class RecentActivityRule extends Rule {
|
||||
window: ActivityWindowType;
|
||||
thresholds: SubThreshold[];
|
||||
thresholds: ActivityThreshold[];
|
||||
useSubmissionAsReference: boolean;
|
||||
lookAt?: 'comments' | 'submissions';
|
||||
|
||||
@@ -84,33 +85,59 @@ export class RecentActivityRule extends Rule {
|
||||
});
|
||||
}
|
||||
}
|
||||
const groupedActivity = viableActivity.reduce((grouped, activity) => {
|
||||
const s = getActivitySubredditName(activity).toLowerCase();
|
||||
grouped[s] = (grouped[s] || []).concat(activity);
|
||||
return grouped;
|
||||
}, {} as Record<string, (Submission | Comment)[]>);
|
||||
|
||||
|
||||
const summaries = [];
|
||||
let totalTriggeredOn;
|
||||
for (const triggerSet of this.thresholds) {
|
||||
let currCount = 0;
|
||||
const presentSubs = [];
|
||||
const presentSubs: string[] = [];
|
||||
let combinedKarma = 0;
|
||||
const {threshold = '>= 1', subreddits = [], karma: karmaThreshold} = triggerSet;
|
||||
for (const sub of subreddits.map(x => parseSubredditName(x))) {
|
||||
const isub = sub.toLowerCase();
|
||||
const {[isub]: tSub = []} = groupedActivity;
|
||||
if (tSub.length > 0) {
|
||||
currCount += tSub.length;
|
||||
presentSubs.push(sub);
|
||||
for(const a of tSub) {
|
||||
combinedKarma += a.score;
|
||||
const {
|
||||
threshold = '>= 1',
|
||||
subreddits = [],
|
||||
karma: karmaThreshold,
|
||||
commentState,
|
||||
submissionState,
|
||||
} = triggerSet;
|
||||
|
||||
// convert subreddits array into entirely StrongSubredditState
|
||||
const subStates: StrongSubredditState[] = subreddits.map((x) => {
|
||||
if(typeof x === 'string') {
|
||||
return toStrongSubredditState({name: x, stateDescription: x}, {defaultFlags: 'i', generateDescription: true});
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
});
|
||||
|
||||
for(const activity of viableActivity) {
|
||||
if(asSubmission(activity) && submissionState !== undefined) {
|
||||
if(!(await this.resources.testItemCriteria(activity, [submissionState]))) {
|
||||
continue;
|
||||
}
|
||||
} else if(commentState !== undefined) {
|
||||
if(!(await this.resources.testItemCriteria(activity, [commentState]))) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let inSubreddits = false;
|
||||
for(const ss of subStates) {
|
||||
const res = await this.resources.testSubredditCriteria(activity, ss);
|
||||
if(res) {
|
||||
inSubreddits = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if(inSubreddits) {
|
||||
currCount++;
|
||||
combinedKarma += activity.score;
|
||||
const pSub = getActivitySubredditName(activity);
|
||||
if(!presentSubs.includes(pSub)) {
|
||||
presentSubs.push(pSub);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const {operator, value, isPercent} = parseGenericValueOrPercentComparison(threshold);
|
||||
let sum = {subsWithActivity: presentSubs, combinedKarma, karmaThreshold, subreddits, count: currCount, threshold, triggered: false, testValue: currCount.toString()};
|
||||
let sum = {subsWithActivity: presentSubs, combinedKarma, karmaThreshold, subreddits: subStates.map(x => x.stateDescription), 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)) {
|
||||
@@ -168,7 +195,10 @@ export class RecentActivityRule extends Rule {
|
||||
karmaThreshold,
|
||||
} = summary;
|
||||
const relevantSubs = subsWithActivity.length === 0 ? subreddits : subsWithActivity;
|
||||
const totalSummary = `${testValue} activities over ${relevantSubs.length} subreddits${karmaThreshold !== undefined ? ` with ${combinedKarma} combined karma` : ''} ${triggered ? 'met' : 'did not meet'} threshold of ${threshold}${karmaThreshold !== undefined ? ` and ${karmaThreshold} combined karma` : ''}`;
|
||||
let totalSummary = `${testValue} activities over ${relevantSubs.length} subreddits${karmaThreshold !== undefined ? ` with ${combinedKarma} combined karma` : ''} ${triggered ? 'met' : 'did not meet'} threshold of ${threshold}${karmaThreshold !== undefined ? ` and ${karmaThreshold} combined karma` : ''}`;
|
||||
if(triggered && subsWithActivity.length > 0) {
|
||||
totalSummary = `${totalSummary} -- subreddits: ${subsWithActivity.join(', ')}`;
|
||||
}
|
||||
return {
|
||||
result: totalSummary,
|
||||
data: {
|
||||
@@ -191,7 +221,16 @@ export class RecentActivityRule extends Rule {
|
||||
* @minProperties 1
|
||||
* @additionalProperties false
|
||||
* */
|
||||
export interface SubThreshold extends SubredditCriteria {
|
||||
export interface ActivityThreshold {
|
||||
/**
|
||||
* When present, a Submission will only be counted if it meets this criteria
|
||||
* */
|
||||
submissionState?: SubmissionState
|
||||
/**
|
||||
* When present, a Comment will only be counted if it meets this criteria
|
||||
* */
|
||||
commentState?: CommentState
|
||||
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare recent activities against
|
||||
*
|
||||
@@ -222,6 +261,20 @@ export interface SubThreshold extends SubredditCriteria {
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
|
||||
* */
|
||||
karma?: string
|
||||
|
||||
/**
|
||||
* Activities will be counted if they are found in this list of Subreddits
|
||||
*
|
||||
* Each value in the list can be either:
|
||||
*
|
||||
* * string (name of subreddit)
|
||||
* * regular expression to run on the subreddit name
|
||||
* * `SubredditState`
|
||||
*
|
||||
* EX `["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]`
|
||||
* @examples [["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]]
|
||||
* */
|
||||
subreddits?: (string | SubredditState)[]
|
||||
}
|
||||
|
||||
interface RecentActivityConfig extends ActivityWindow, ReferenceSubmission {
|
||||
@@ -234,7 +287,7 @@ interface RecentActivityConfig extends ActivityWindow, ReferenceSubmission {
|
||||
* A list of subreddits/count criteria that may trigger this rule. ANY SubThreshold will trigger this rule.
|
||||
* @minItems 1
|
||||
* */
|
||||
thresholds: SubThreshold[],
|
||||
thresholds: ActivityThreshold[],
|
||||
}
|
||||
|
||||
export interface RecentActivityRuleOptions extends RecentActivityConfig, RuleOptions {
|
||||
|
||||
@@ -4,13 +4,14 @@ import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {
|
||||
asSubmission,
|
||||
comparisonTextOp, FAIL, isExternalUrlSubmission, isSubmission, parseGenericValueComparison,
|
||||
parseGenericValueOrPercentComparison, parseRegex,
|
||||
PASS
|
||||
parseGenericValueOrPercentComparison, parseRegex, parseStringToRegex,
|
||||
PASS, triggeredIndicator
|
||||
} from "../util";
|
||||
import {
|
||||
ActivityWindowType, JoinOperands,
|
||||
} from "../Common/interfaces";
|
||||
import dayjs from 'dayjs';
|
||||
import SimpleError from "../Utils/SimpleError";
|
||||
|
||||
export interface RegexCriteria {
|
||||
/**
|
||||
@@ -22,17 +23,11 @@ export interface RegexCriteria {
|
||||
/**
|
||||
* A valid Regular Expression to test content against
|
||||
*
|
||||
* Do not wrap expression in forward slashes
|
||||
* If no flags are specified then the **global** flag is used by default
|
||||
*
|
||||
* EX For the expression `/reddit|FoxxMD/` use the value should be `reddit|FoxxMD`
|
||||
*
|
||||
* @examples ["reddit|FoxxMD"]
|
||||
* @examples ["/reddit|FoxxMD/ig"]
|
||||
* */
|
||||
regex: string,
|
||||
/**
|
||||
* Regex flags to use
|
||||
* */
|
||||
regexFlags?: string,
|
||||
|
||||
/**
|
||||
* Which content from an Activity to test the regex against
|
||||
@@ -135,12 +130,11 @@ export class RegexRule extends Rule {
|
||||
|
||||
let criteriaResults = [];
|
||||
|
||||
for (const criteria of this.criteria) {
|
||||
for (const [index, criteria] of this.criteria.entries()) {
|
||||
|
||||
const {
|
||||
name,
|
||||
name = (index + 1),
|
||||
regex,
|
||||
regexFlags,
|
||||
testOn: testOnVals = ['title', 'body'],
|
||||
lookAt = 'all',
|
||||
matchThreshold = '> 0',
|
||||
@@ -158,7 +152,10 @@ export class RegexRule extends Rule {
|
||||
}, []);
|
||||
|
||||
// check regex
|
||||
const reg = new RegExp(regex);
|
||||
const reg = parseStringToRegex(regex, 'g');
|
||||
if(reg === undefined) {
|
||||
throw new SimpleError(`Value given for regex on Criteria ${name} was not valid: ${regex}`);
|
||||
}
|
||||
// ok cool its a valid regex
|
||||
|
||||
const matchComparison = parseGenericValueComparison(matchThreshold);
|
||||
@@ -177,7 +174,7 @@ export class RegexRule extends Rule {
|
||||
|
||||
// first lets see if the activity we are checking satisfies thresholds
|
||||
// since we may be able to avoid api calls to get history
|
||||
let actMatches = this.getMatchesFromActivity(item, testOn, reg, regexFlags);
|
||||
let actMatches = this.getMatchesFromActivity(item, testOn, reg);
|
||||
matches = matches.concat(actMatches).slice(0, 100);
|
||||
matchCount += actMatches.length;
|
||||
|
||||
@@ -227,7 +224,7 @@ export class RegexRule extends Rule {
|
||||
|
||||
for (const h of history) {
|
||||
activitiesTested++;
|
||||
const aMatches = this.getMatchesFromActivity(h, testOn, reg, regexFlags);
|
||||
const aMatches = this.getMatchesFromActivity(h, testOn, reg);
|
||||
matches = matches.concat(aMatches).slice(0, 100);
|
||||
matchCount += aMatches.length;
|
||||
const matched = comparisonTextOp(aMatches.length, matchComparison.operator, matchComparison.value);
|
||||
@@ -301,26 +298,31 @@ export class RegexRule extends Rule {
|
||||
let index = 0;
|
||||
for (const c of criteriaResults) {
|
||||
index++;
|
||||
let msg = `Crit ${c.criteria.name || index} ${c.triggered ? PASS : FAIL}`;
|
||||
let msg = `Criteria ${c.criteria.name || `#${index}`} ${triggeredIndicator(c.triggered)}`;
|
||||
if (c.activityThresholdMet !== undefined) {
|
||||
msg = `${msg} -- Activity Match=> ${c.activityThresholdMet ? PASS : FAIL} ${c.activitiesMatchedCount} ${c.criteria.activityMatchThreshold} (Threshold ${c.criteria.matchThreshold})`;
|
||||
msg = `${msg} -- Activity Match ${triggeredIndicator(c.activityThresholdMet)} => ${c.activitiesMatchedCount} ${c.criteria.activityMatchThreshold} (Threshold ${c.criteria.matchThreshold})`;
|
||||
}
|
||||
if (c.totalThresholdMet !== undefined) {
|
||||
msg = `${msg} -- Total Matches=> ${c.totalThresholdMet ? PASS : FAIL} ${c.matchCount} ${c.criteria.totalMatchThreshold}`;
|
||||
msg = `${msg} -- Total Matches ${triggeredIndicator(c.totalThresholdMet)} => ${c.matchCount} ${c.criteria.totalMatchThreshold}`;
|
||||
} else {
|
||||
msg = `${msg} and ${c.matchCount} Total Matches`;
|
||||
}
|
||||
msg = `${msg} (Window: ${c.criteria.window})`;
|
||||
logSummary.push(msg);
|
||||
if(c.matches.length > 0) {
|
||||
let matchSample = `-- Matched Values: ${c.matches.slice(0, 3).map(x => `"${x}"`).join(', ')}${c.matches.length > 3 ? `, and ${c.matches.length - 3} more...` : ''}`;
|
||||
logSummary.push(`${msg} ${matchSample}`);
|
||||
} else {
|
||||
logSummary.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const result = `${criteriaMet ? PASS : FAIL} ${logSummary.join(' || ')}`;
|
||||
const result = `${triggeredIndicator(criteriaMet)} ${logSummary.join(' || ')}`;
|
||||
this.logger.verbose(result);
|
||||
|
||||
return Promise.resolve([criteriaMet, this.getResult(criteriaMet, {result, data: criteriaResults})]);
|
||||
}
|
||||
|
||||
protected getMatchesFromActivity(a: (Submission | Comment), testOn: string[], reg: RegExp, flags?: string): string[] {
|
||||
protected getMatchesFromActivity(a: (Submission | Comment), testOn: string[], reg: RegExp): string[] {
|
||||
let m: string[] = [];
|
||||
// determine what content we are testing
|
||||
let contents: string[] = [];
|
||||
@@ -347,7 +349,7 @@ export class RegexRule extends Rule {
|
||||
}
|
||||
|
||||
for (const c of contents) {
|
||||
const results = parseRegex(reg, c, flags);
|
||||
const results = parseRegex(reg, c);
|
||||
if (results.matched) {
|
||||
m = m.concat(results.matches);
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import {
|
||||
activityWindowText, asSubmission,
|
||||
comparisonTextOp, FAIL, getActivitySubredditName, isExternalUrlSubmission, isRedditMedia,
|
||||
parseGenericValueComparison, parseSubredditName,
|
||||
parseUsableLinkIdentifier as linkParser, PASS
|
||||
parseUsableLinkIdentifier as linkParser, PASS, toStrongSubredditState
|
||||
} from "../util";
|
||||
import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../Common/interfaces";
|
||||
import {
|
||||
ActivityWindow,
|
||||
ActivityWindowType,
|
||||
ReferenceSubmission,
|
||||
StrongSubredditState,
|
||||
SubredditState
|
||||
} from "../Common/interfaces";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import dayjs from "dayjs";
|
||||
import Fuse from 'fuse.js'
|
||||
@@ -50,8 +56,9 @@ export class RepeatActivityRule extends Rule {
|
||||
gapAllowance?: number;
|
||||
useSubmissionAsReference: boolean;
|
||||
lookAt: 'submissions' | 'all';
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
include: (string | SubredditState)[];
|
||||
exclude: (string | SubredditState)[];
|
||||
activityFilterFunc: (x: Submission|Comment) => Promise<boolean> = async (x) => true;
|
||||
keepRemoved: boolean;
|
||||
minWordCount: number;
|
||||
|
||||
@@ -74,8 +81,40 @@ export class RepeatActivityRule extends Rule {
|
||||
this.window = window;
|
||||
this.gapAllowance = gapAllowance;
|
||||
this.useSubmissionAsReference = useSubmissionAsReference;
|
||||
this.include = include.map(x => parseSubredditName(x).toLowerCase());
|
||||
this.exclude = exclude.map(x => parseSubredditName(x).toLowerCase());
|
||||
this.include = include;
|
||||
this.exclude = exclude;
|
||||
|
||||
if(this.include.length > 0) {
|
||||
const subStates = include.map((x) => {
|
||||
if(typeof x === 'string') {
|
||||
return toStrongSubredditState({name: x, stateDescription: x}, {defaultFlags: 'i', generateDescription: true});
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
});
|
||||
this.activityFilterFunc = async (x: Submission|Comment) => {
|
||||
for(const ss of subStates) {
|
||||
if(await this.resources.testSubredditCriteria(x, ss)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
} else if(this.exclude.length > 0) {
|
||||
const subStates = exclude.map((x) => {
|
||||
if(typeof x === 'string') {
|
||||
return toStrongSubredditState({name: x, stateDescription: x}, {defaultFlags: 'i', generateDescription: true});
|
||||
}
|
||||
return toStrongSubredditState(x, {defaultFlags: 'i', generateDescription: true});
|
||||
});
|
||||
this.activityFilterFunc = async (x: Submission|Comment) => {
|
||||
for(const ss of subStates) {
|
||||
if(await this.resources.testSubredditCriteria(x, ss)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
}
|
||||
this.lookAt = lookAt;
|
||||
}
|
||||
|
||||
@@ -100,13 +139,6 @@ export class RepeatActivityRule extends Rule {
|
||||
referenceUrl = await item.url;
|
||||
}
|
||||
|
||||
let filterFunc = (x: any) => true;
|
||||
if(this.include.length > 0) {
|
||||
filterFunc = (x: Submission|Comment) => this.include.includes(getActivitySubredditName(x).toLowerCase());
|
||||
} else if(this.exclude.length > 0) {
|
||||
filterFunc = (x: Submission|Comment) => !this.exclude.includes(getActivitySubredditName(x).toLowerCase());
|
||||
}
|
||||
|
||||
let activities: (Submission | Comment)[] = [];
|
||||
switch (this.lookAt) {
|
||||
case 'submissions':
|
||||
@@ -117,13 +149,14 @@ export class RepeatActivityRule extends Rule {
|
||||
break;
|
||||
}
|
||||
|
||||
const condensedActivities = activities.reduce((acc: RepeatActivityReducer, activity: (Submission | Comment), index: number) => {
|
||||
const condensedActivities = await activities.reduce(async (accProm: Promise<RepeatActivityReducer>, activity: (Submission | Comment), index: number) => {
|
||||
const acc = await accProm;
|
||||
const {openSets = [], allSets = []} = acc;
|
||||
|
||||
let identifier = getActivityIdentifier(activity);
|
||||
const isUrl = isExternalUrlSubmission(activity);
|
||||
let fu = new Fuse([identifier], !isUrl ? fuzzyOptions : {...fuzzyOptions, distance: 5});
|
||||
const validSub = filterFunc(activity);
|
||||
const validSub = await this.activityFilterFunc(activity);
|
||||
let minMet = identifier.length >= this.minWordCount;
|
||||
|
||||
let updatedAllSets = [...allSets];
|
||||
@@ -174,7 +207,7 @@ export class RepeatActivityRule extends Rule {
|
||||
|
||||
return {openSets: updatedOpenSets, allSets: updatedAllSets};
|
||||
|
||||
}, {openSets: [], allSets: []});
|
||||
}, Promise.resolve({openSets: [], allSets: []}));
|
||||
|
||||
const allRepeatSets = [...condensedActivities.allSets, ...condensedActivities.openSets];
|
||||
|
||||
@@ -294,21 +327,31 @@ interface RepeatActivityConfig extends ActivityWindow, ReferenceSubmission {
|
||||
* */
|
||||
gapAllowance?: number,
|
||||
/**
|
||||
* Only include Submissions from this list of Subreddits (by name, case-insensitive)
|
||||
* If present, activities will be counted only if they are found in this list of Subreddits
|
||||
*
|
||||
* EX `["mealtimevideos","askscience"]`
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* Each value in the list can be either:
|
||||
*
|
||||
* * string (name of subreddit)
|
||||
* * regular expression to run on the subreddit name
|
||||
* * `SubredditState`
|
||||
*
|
||||
* EX `["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]`
|
||||
* @examples [["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]]
|
||||
* */
|
||||
include?: string[],
|
||||
include?: (string | SubredditState)[],
|
||||
/**
|
||||
* Do not include Submissions from this list of Subreddits (by name, case-insensitive)
|
||||
* If present, activities will be counted only if they are **NOT** found in this list of Subreddits
|
||||
*
|
||||
* EX `["mealtimevideos","askscience"]`
|
||||
* @examples ["mealtimevideos","askscience"]
|
||||
* @minItems 1
|
||||
* Each value in the list can be either:
|
||||
*
|
||||
* * string (name of subreddit)
|
||||
* * regular expression to run on the subreddit name
|
||||
* * `SubredditState`
|
||||
*
|
||||
* EX `["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]`
|
||||
* @examples [["mealtimevideos","askscience", "/onlyfans*\/i", {"over18": true}]]
|
||||
* */
|
||||
exclude?: string[],
|
||||
exclude?: (string | SubredditState)[],
|
||||
|
||||
/**
|
||||
* If present determines which activities to consider for gapAllowance.
|
||||
|
||||
@@ -32,6 +32,11 @@ export interface RuleResult extends ResultContext {
|
||||
triggered: (boolean | null)
|
||||
}
|
||||
|
||||
export type FormattedRuleResult = RuleResult & {
|
||||
triggered: string
|
||||
result: string
|
||||
}
|
||||
|
||||
export interface RuleSetResult {
|
||||
results: RuleResult[],
|
||||
condition: 'OR' | 'AND',
|
||||
|
||||
@@ -154,6 +154,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -213,6 +223,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ActivityThreshold": {
|
||||
"additionalProperties": false,
|
||||
"description": "At least one count property must be present. If both are present then either can trigger the rule",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"commentState": {
|
||||
"$ref": "#/definitions/CommentState",
|
||||
"description": "When present, a Comment will only be counted if it meets this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"op": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"karma": {
|
||||
"description": "Test the **combined karma** from Activities found in the specified subreddits\n\nValue is a string containing a comparison operator and a number of **combined karma** to compare against\n\nIf specified then both `threshold` and `karma` must be met for this `SubThreshold` to be satisfied\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => greater than 50 combined karma for all found Activities in specified subreddits",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"submissionState": {
|
||||
"$ref": "#/definitions/SubmissionState",
|
||||
"description": "When present, a Submission will only be counted if it meets this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"over_18": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "Activities will be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ActivityWindowCriteria": {
|
||||
"additionalProperties": false,
|
||||
"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.",
|
||||
@@ -167,7 +233,7 @@
|
||||
"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",
|
||||
"description": "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": [
|
||||
[
|
||||
]
|
||||
@@ -195,7 +261,7 @@
|
||||
[
|
||||
]
|
||||
],
|
||||
"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`",
|
||||
"description": "A list of domains whose Activities will be tested against `threshold`.\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"
|
||||
},
|
||||
@@ -688,6 +754,11 @@
|
||||
},
|
||||
"CacheConfig": {
|
||||
"properties": {
|
||||
"actionedEventsMax": {
|
||||
"default": 25,
|
||||
"description": "The **maximum** number of Events that the cache should store triggered result summaries for\n\nThese summaries are viewable through the Web UI.\n\nThe value specified by a subreddit cannot be larger than the value set by the Operator for the global/bot config (if set)",
|
||||
"type": "number"
|
||||
},
|
||||
"authorTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
|
||||
@@ -748,6 +819,17 @@
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"subredditTTL": {
|
||||
"default": 600,
|
||||
"description": "Amount of time, in seconds, a subreddit (attributes) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
600
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"userNotesTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
@@ -1156,6 +1238,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1336,23 +1428,28 @@
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryCriteria": {
|
||||
"description": "If both `submission` and `comment` are defined then criteria will only trigger if BOTH thresholds are met",
|
||||
"description": "Criteria will only trigger if ALL present thresholds (comment, submission, total) are met",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"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**",
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) 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 unfiltered Activities\n\nIf your string also contains the text `OP` somewhere **after** `<number>[percent sign]`...:\n\n* EX `> 100 OP` => greater than 100 filtered comments as OP\n* EX `<= 25% as OP` => **Filtered** comments as OP were less then or equal to 25% of **unfiltered Comments**",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"minActivityCount": {
|
||||
"default": 5,
|
||||
"description": "The minimum number of activities that must exist from the `window` results for this criteria to run",
|
||||
"description": "The minimum number of **filtered** activities that must exist from the `window` results for this criteria to run",
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"submission": {
|
||||
"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",
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`) activities against\n\n**Note:** This is only useful if using `include` or `exclude` otherwise percent will always be 100% and total === activityTotal\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [OP]`\n\n* EX `> 100` => greater than 100 filtered activities\n* EX `<= 75%` => filtered activities are equal to or less than 75% of all Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1421,27 +1518,51 @@
|
||||
"type": "array"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are **NOT** found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`\n\n**Note:** This affects **post-window retrieval** activities. So that:\n\n* `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering\n* all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**\n* -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are found in this list of Subreddits.\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`\n\n **Note:** This affects **post-window retrieval** activities. So that:\n\n* `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering\n* all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**\n* -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -1656,6 +1777,16 @@
|
||||
"title": {
|
||||
"description": "The title of the message\n\nIf not specified will be defaulted to `Concerning your [Submission/Comment]`",
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
|
||||
"examples": [
|
||||
"aUserName",
|
||||
"u/aUserName",
|
||||
"r/aSubreddit"
|
||||
],
|
||||
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1848,7 +1979,7 @@
|
||||
"thresholds": {
|
||||
"description": "A list of subreddits/count criteria that may trigger this rule. ANY SubThreshold will trigger this rule.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SubThreshold"
|
||||
"$ref": "#/definitions/ActivityThreshold"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
@@ -1885,6 +2016,49 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegExp": {
|
||||
"properties": {
|
||||
"dotAll": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"flags": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ignoreCase": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastIndex": {
|
||||
"type": "number"
|
||||
},
|
||||
"multiline": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"sticky": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unicode": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dotAll",
|
||||
"flags",
|
||||
"global",
|
||||
"ignoreCase",
|
||||
"lastIndex",
|
||||
"multiline",
|
||||
"source",
|
||||
"sticky",
|
||||
"unicode"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegexCriteria": {
|
||||
"properties": {
|
||||
"activityMatchThreshold": {
|
||||
@@ -1923,16 +2097,12 @@
|
||||
"type": "string"
|
||||
},
|
||||
"regex": {
|
||||
"description": "A valid Regular Expression to test content against\n\nDo not wrap expression in forward slashes\n\nEX For the expression `/reddit|FoxxMD/` use the value should be `reddit|FoxxMD`",
|
||||
"description": "A valid Regular Expression to test content against\n\nIf no flags are specified then the **global** flag is used by default",
|
||||
"examples": [
|
||||
"reddit|FoxxMD"
|
||||
"/reddit|FoxxMD/ig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"regexFlags": {
|
||||
"description": "Regex flags to use",
|
||||
"type": "string"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"title",
|
||||
@@ -2171,15 +2341,27 @@
|
||||
]
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are **NOT** found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"gapAllowance": {
|
||||
@@ -2187,15 +2369,27 @@
|
||||
"type": "number"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -2422,45 +2616,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubThreshold": {
|
||||
"additionalProperties": false,
|
||||
"description": "At least one count property must be present. If both are present then either can trigger the rule",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"karma": {
|
||||
"description": "Test the **combined karma** from Activities found in the specified subreddits\n\nValue is a string containing a comparison operator and a number of **combined karma** to compare against\n\nIf specified then both `threshold` and `karma` must be met for this `SubThreshold` to be satisfied\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => greater than 50 combined karma for all found Activities in specified subreddits",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "A list of Subreddits (by name, case-insensitive) to look for.\n\nEX [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"subreddits"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionCheckJson": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
@@ -2681,6 +2836,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2697,6 +2862,44 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SubredditState": {
|
||||
"description": "Different attributes a `Subreddit` can be in. Only include a property if you want to check it.",
|
||||
"examples": [
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RegExp"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "The name the subreddit.\n\nCan be a normal string (will check case-insensitive) or a regular expression\n\nEX `[\"mealtimevideos\", \"/onlyfans*\\/i\"]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"/onlyfans*/i"
|
||||
]
|
||||
},
|
||||
"over18": {
|
||||
"description": "Is subreddit NSFW/over 18?\n\n**Note**: This is **mod-controlled flag** so it is up to the mods of the subreddit to correctly mark their subreddit as NSFW",
|
||||
"type": "boolean"
|
||||
},
|
||||
"quarantine": {
|
||||
"description": "Is subreddit quarantined?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stateDescription": {
|
||||
"description": "A friendly description of what this State is trying to parse",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserNoteActionJson": {
|
||||
"description": "Add a Toolbox User Note to the Author of this Activity",
|
||||
"properties": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"description": "The configuration for an **individual reddit account** ContextMod will run as a bot.\n\nMultiple bot configs may be specified (one per reddit account).\n\n**NOTE:** If `bots` is not specified in a `FILE` then a default `bot` is generated using `ENV/ARG` values IE `CLIENT_ID`, etc...but if `bots` IS specified the default is not generated.",
|
||||
"properties": {
|
||||
"caching": {
|
||||
"$ref": "#/definitions/CacheConfig",
|
||||
"$ref": "#/definitions/OperatorCacheConfig",
|
||||
"description": "Settings to configure the default caching behavior for this bot\n\nEvery setting not specified will default to what is specified by the global operator caching config"
|
||||
},
|
||||
"credentials": {
|
||||
@@ -176,93 +176,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CacheConfig": {
|
||||
"properties": {
|
||||
"authorTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"commentTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a comment should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"filterCriteriaTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)\n\nThis is especially useful if when polling high-volume comments and your checks rely on author/item filters\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CacheOptions"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"memory",
|
||||
"none",
|
||||
"redis"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "The cache provider and, optionally, a custom configuration for that provider\n\nIf not present or `null` provider will be `memory`.\n\nTo specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`"
|
||||
},
|
||||
"submissionTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a submission should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"userNotesTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"wikiTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, wiki content pages should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CacheOptions": {
|
||||
"additionalProperties": {
|
||||
},
|
||||
@@ -417,6 +330,114 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"OperatorCacheConfig": {
|
||||
"properties": {
|
||||
"actionedEventsDefault": {
|
||||
"default": 25,
|
||||
"description": "The **default** number of Events that the cache will store triggered result summaries for\n\nThese summaries are viewable through the Web UI.\n\nThe value specified cannot be larger than `actionedEventsMax` for the global/bot config (if set)",
|
||||
"type": "number"
|
||||
},
|
||||
"actionedEventsMax": {
|
||||
"default": 25,
|
||||
"description": "The **maximum** number of Events that the cache should store triggered result summaries for\n\nThese summaries are viewable through the Web UI.\n\nThe value specified by a subreddit cannot be larger than the value set by the Operator for the global/bot config (if set)",
|
||||
"type": "number"
|
||||
},
|
||||
"authorTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"commentTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a comment should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"filterCriteriaTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)\n\nThis is especially useful if when polling high-volume comments and your checks rely on author/item filters\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CacheOptions"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"memory",
|
||||
"none",
|
||||
"redis"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "The cache provider and, optionally, a custom configuration for that provider\n\nIf not present or `null` provider will be `memory`.\n\nTo specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`"
|
||||
},
|
||||
"submissionTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a submission should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"subredditTTL": {
|
||||
"default": 600,
|
||||
"description": "Amount of time, in seconds, a subreddit (attributes) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
600
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"userNotesTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"wikiTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, wiki content pages should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"PollingDefaults": {
|
||||
"properties": {
|
||||
"delayUntil": {
|
||||
@@ -551,7 +572,7 @@
|
||||
"type": "array"
|
||||
},
|
||||
"caching": {
|
||||
"$ref": "#/definitions/CacheConfig",
|
||||
"$ref": "#/definitions/OperatorCacheConfig",
|
||||
"description": "Settings to configure the default caching behavior globally\n\nThese settings will be used by each bot, and subreddit, that does not specify their own"
|
||||
},
|
||||
"logging": {
|
||||
|
||||
@@ -24,6 +24,72 @@
|
||||
}
|
||||
],
|
||||
"definitions": {
|
||||
"ActivityThreshold": {
|
||||
"additionalProperties": false,
|
||||
"description": "At least one count property must be present. If both are present then either can trigger the rule",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"commentState": {
|
||||
"$ref": "#/definitions/CommentState",
|
||||
"description": "When present, a Comment will only be counted if it meets this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"op": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"karma": {
|
||||
"description": "Test the **combined karma** from Activities found in the specified subreddits\n\nValue is a string containing a comparison operator and a number of **combined karma** to compare against\n\nIf specified then both `threshold` and `karma` must be met for this `SubThreshold` to be satisfied\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => greater than 50 combined karma for all found Activities in specified subreddits",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"submissionState": {
|
||||
"$ref": "#/definitions/SubmissionState",
|
||||
"description": "When present, a Submission will only be counted if it meets this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"over_18": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "Activities will be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ActivityWindowCriteria": {
|
||||
"additionalProperties": false,
|
||||
"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.",
|
||||
@@ -113,7 +179,7 @@
|
||||
"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",
|
||||
"description": "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": [
|
||||
[
|
||||
]
|
||||
@@ -141,7 +207,7 @@
|
||||
[
|
||||
]
|
||||
],
|
||||
"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`",
|
||||
"description": "A list of domains whose Activities will be tested against `threshold`.\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"
|
||||
},
|
||||
@@ -538,6 +604,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -611,23 +687,28 @@
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryCriteria": {
|
||||
"description": "If both `submission` and `comment` are defined then criteria will only trigger if BOTH thresholds are met",
|
||||
"description": "Criteria will only trigger if ALL present thresholds (comment, submission, total) are met",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"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**",
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) 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 unfiltered Activities\n\nIf your string also contains the text `OP` somewhere **after** `<number>[percent sign]`...:\n\n* EX `> 100 OP` => greater than 100 filtered comments as OP\n* EX `<= 25% as OP` => **Filtered** comments as OP were less then or equal to 25% of **unfiltered Comments**",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"minActivityCount": {
|
||||
"default": 5,
|
||||
"description": "The minimum number of activities that must exist from the `window` results for this criteria to run",
|
||||
"description": "The minimum number of **filtered** activities that must exist from the `window` results for this criteria to run",
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"submission": {
|
||||
"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",
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`) activities against\n\n**Note:** This is only useful if using `include` or `exclude` otherwise percent will always be 100% and total === activityTotal\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [OP]`\n\n* EX `> 100` => greater than 100 filtered activities\n* EX `<= 75%` => filtered activities are equal to or less than 75% of all Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -696,27 +777,51 @@
|
||||
"type": "array"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are **NOT** found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`\n\n**Note:** This affects **post-window retrieval** activities. So that:\n\n* `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering\n* all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**\n* -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are found in this list of Subreddits.\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`\n\n **Note:** This affects **post-window retrieval** activities. So that:\n\n* `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering\n* all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**\n* -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -830,7 +935,7 @@
|
||||
"thresholds": {
|
||||
"description": "A list of subreddits/count criteria that may trigger this rule. ANY SubThreshold will trigger this rule.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SubThreshold"
|
||||
"$ref": "#/definitions/ActivityThreshold"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
@@ -867,6 +972,49 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegExp": {
|
||||
"properties": {
|
||||
"dotAll": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"flags": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ignoreCase": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastIndex": {
|
||||
"type": "number"
|
||||
},
|
||||
"multiline": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"sticky": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unicode": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dotAll",
|
||||
"flags",
|
||||
"global",
|
||||
"ignoreCase",
|
||||
"lastIndex",
|
||||
"multiline",
|
||||
"source",
|
||||
"sticky",
|
||||
"unicode"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegexCriteria": {
|
||||
"properties": {
|
||||
"activityMatchThreshold": {
|
||||
@@ -905,16 +1053,12 @@
|
||||
"type": "string"
|
||||
},
|
||||
"regex": {
|
||||
"description": "A valid Regular Expression to test content against\n\nDo not wrap expression in forward slashes\n\nEX For the expression `/reddit|FoxxMD/` use the value should be `reddit|FoxxMD`",
|
||||
"description": "A valid Regular Expression to test content against\n\nIf no flags are specified then the **global** flag is used by default",
|
||||
"examples": [
|
||||
"reddit|FoxxMD"
|
||||
"/reddit|FoxxMD/ig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"regexFlags": {
|
||||
"description": "Regex flags to use",
|
||||
"type": "string"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"title",
|
||||
@@ -1076,15 +1220,27 @@
|
||||
]
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are **NOT** found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"gapAllowance": {
|
||||
@@ -1092,15 +1248,27 @@
|
||||
"type": "number"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -1190,45 +1358,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubThreshold": {
|
||||
"additionalProperties": false,
|
||||
"description": "At least one count property must be present. If both are present then either can trigger the rule",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"karma": {
|
||||
"description": "Test the **combined karma** from Activities found in the specified subreddits\n\nValue is a string containing a comparison operator and a number of **combined karma** to compare against\n\nIf specified then both `threshold` and `karma` must be met for this `SubThreshold` to be satisfied\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => greater than 50 combined karma for all found Activities in specified subreddits",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "A list of Subreddits (by name, case-insensitive) to look for.\n\nEX [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"subreddits"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionState": {
|
||||
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
|
||||
"examples": [
|
||||
@@ -1272,6 +1401,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1288,6 +1427,44 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SubredditState": {
|
||||
"description": "Different attributes a `Subreddit` can be in. Only include a property if you want to check it.",
|
||||
"examples": [
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RegExp"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "The name the subreddit.\n\nCan be a normal string (will check case-insensitive) or a regular expression\n\nEX `[\"mealtimevideos\", \"/onlyfans*\\/i\"]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"/onlyfans*/i"
|
||||
]
|
||||
},
|
||||
"over18": {
|
||||
"description": "Is subreddit NSFW/over 18?\n\n**Note**: This is **mod-controlled flag** so it is up to the mods of the subreddit to correctly mark their subreddit as NSFW",
|
||||
"type": "boolean"
|
||||
},
|
||||
"quarantine": {
|
||||
"description": "Is subreddit quarantined?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stateDescription": {
|
||||
"description": "A friendly description of what this State is trying to parse",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserNoteCriteria": {
|
||||
"properties": {
|
||||
"count": {
|
||||
|
||||
@@ -1,6 +1,72 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"ActivityThreshold": {
|
||||
"additionalProperties": false,
|
||||
"description": "At least one count property must be present. If both are present then either can trigger the rule",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"commentState": {
|
||||
"$ref": "#/definitions/CommentState",
|
||||
"description": "When present, a Comment will only be counted if it meets this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"op": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"karma": {
|
||||
"description": "Test the **combined karma** from Activities found in the specified subreddits\n\nValue is a string containing a comparison operator and a number of **combined karma** to compare against\n\nIf specified then both `threshold` and `karma` must be met for this `SubThreshold` to be satisfied\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => greater than 50 combined karma for all found Activities in specified subreddits",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"submissionState": {
|
||||
"$ref": "#/definitions/SubmissionState",
|
||||
"description": "When present, a Submission will only be counted if it meets this criteria",
|
||||
"examples": [
|
||||
{
|
||||
"over_18": true,
|
||||
"removed": false
|
||||
}
|
||||
]
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "Activities will be counted if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ActivityWindowCriteria": {
|
||||
"additionalProperties": false,
|
||||
"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.",
|
||||
@@ -90,7 +156,7 @@
|
||||
"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",
|
||||
"description": "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": [
|
||||
[
|
||||
]
|
||||
@@ -118,7 +184,7 @@
|
||||
[
|
||||
]
|
||||
],
|
||||
"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`",
|
||||
"description": "A list of domains whose Activities will be tested against `threshold`.\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"
|
||||
},
|
||||
@@ -515,6 +581,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -588,23 +664,28 @@
|
||||
"type": "object"
|
||||
},
|
||||
"HistoryCriteria": {
|
||||
"description": "If both `submission` and `comment` are defined then criteria will only trigger if BOTH thresholds are met",
|
||||
"description": "Criteria will only trigger if ALL present thresholds (comment, submission, total) are met",
|
||||
"properties": {
|
||||
"comment": {
|
||||
"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**",
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) 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 unfiltered Activities\n\nIf your string also contains the text `OP` somewhere **after** `<number>[percent sign]`...:\n\n* EX `> 100 OP` => greater than 100 filtered comments as OP\n* EX `<= 25% as OP` => **Filtered** comments as OP were less then or equal to 25% of **unfiltered Comments**",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"minActivityCount": {
|
||||
"default": 5,
|
||||
"description": "The minimum number of activities that must exist from the `window` results for this criteria to run",
|
||||
"description": "The minimum number of **filtered** activities that must exist from the `window` results for this criteria to run",
|
||||
"type": "number"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"submission": {
|
||||
"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",
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"total": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`) activities against\n\n**Note:** This is only useful if using `include` or `exclude` otherwise percent will always be 100% and total === activityTotal\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [OP]`\n\n* EX `> 100` => greater than 100 filtered activities\n* EX `<= 75%` => filtered activities are equal to or less than 75% of all Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -673,27 +754,51 @@
|
||||
"type": "array"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are **NOT** found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`\n\n**Note:** This affects **post-window retrieval** activities. So that:\n\n* `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering\n* all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**\n* -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are found in this list of Subreddits.\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`\n\n **Note:** This affects **post-window retrieval** activities. So that:\n\n* `activityTotal` is number of activities retrieved from `window` -- NOT post-filtering\n* all comparisons using **percentages** will compare **post-filtering** results against **activity count from window**\n* -- to run this rule where all activities are only from include/exclude filtering instead use include/exclude in `window`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -807,7 +912,7 @@
|
||||
"thresholds": {
|
||||
"description": "A list of subreddits/count criteria that may trigger this rule. ANY SubThreshold will trigger this rule.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/SubThreshold"
|
||||
"$ref": "#/definitions/ActivityThreshold"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
@@ -844,6 +949,49 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegExp": {
|
||||
"properties": {
|
||||
"dotAll": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"flags": {
|
||||
"type": "string"
|
||||
},
|
||||
"global": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"ignoreCase": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"lastIndex": {
|
||||
"type": "number"
|
||||
},
|
||||
"multiline": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
},
|
||||
"sticky": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unicode": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"dotAll",
|
||||
"flags",
|
||||
"global",
|
||||
"ignoreCase",
|
||||
"lastIndex",
|
||||
"multiline",
|
||||
"source",
|
||||
"sticky",
|
||||
"unicode"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RegexCriteria": {
|
||||
"properties": {
|
||||
"activityMatchThreshold": {
|
||||
@@ -882,16 +1030,12 @@
|
||||
"type": "string"
|
||||
},
|
||||
"regex": {
|
||||
"description": "A valid Regular Expression to test content against\n\nDo not wrap expression in forward slashes\n\nEX For the expression `/reddit|FoxxMD/` use the value should be `reddit|FoxxMD`",
|
||||
"description": "A valid Regular Expression to test content against\n\nIf no flags are specified then the **global** flag is used by default",
|
||||
"examples": [
|
||||
"reddit|FoxxMD"
|
||||
"/reddit|FoxxMD/ig"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"regexFlags": {
|
||||
"description": "Regex flags to use",
|
||||
"type": "string"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"title",
|
||||
@@ -1053,15 +1197,27 @@
|
||||
]
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Do not include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are **NOT** found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"gapAllowance": {
|
||||
@@ -1069,15 +1225,27 @@
|
||||
"type": "number"
|
||||
},
|
||||
"include": {
|
||||
"description": "Only include Submissions from this list of Subreddits (by name, case-insensitive)\n\nEX `[\"mealtimevideos\",\"askscience\"]`",
|
||||
"description": "If present, activities will be counted only if they are found in this list of Subreddits\n\nEach value in the list can be either:\n\n * string (name of subreddit)\n * regular expression to run on the subreddit name\n * `SubredditState`\n\nEX `[\"mealtimevideos\",\"askscience\", \"/onlyfans*\\/i\", {\"over18\": true}]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience",
|
||||
"/onlyfans*/i",
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubredditState"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -1167,45 +1335,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubThreshold": {
|
||||
"additionalProperties": false,
|
||||
"description": "At least one count property must be present. If both are present then either can trigger the rule",
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"karma": {
|
||||
"description": "Test the **combined karma** from Activities found in the specified subreddits\n\nValue is a string containing a comparison operator and a number of **combined karma** to compare against\n\nIf specified then both `threshold` and `karma` must be met for this `SubThreshold` to be satisfied\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => greater than 50 combined karma for all found Activities in specified subreddits",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "A list of Subreddits (by name, case-insensitive) to look for.\n\nEX [\"mealtimevideos\",\"askscience\"]",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"askscience"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"subreddits"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionState": {
|
||||
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
|
||||
"examples": [
|
||||
@@ -1249,6 +1378,16 @@
|
||||
"removed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"reports": {
|
||||
"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"
|
||||
},
|
||||
"score": {
|
||||
"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"
|
||||
},
|
||||
"spam": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1265,6 +1404,44 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SubredditState": {
|
||||
"description": "Different attributes a `Subreddit` can be in. Only include a property if you want to check it.",
|
||||
"examples": [
|
||||
{
|
||||
"over18": true
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/RegExp"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "The name the subreddit.\n\nCan be a normal string (will check case-insensitive) or a regular expression\n\nEX `[\"mealtimevideos\", \"/onlyfans*\\/i\"]`",
|
||||
"examples": [
|
||||
"mealtimevideos",
|
||||
"/onlyfans*/i"
|
||||
]
|
||||
},
|
||||
"over18": {
|
||||
"description": "Is subreddit NSFW/over 18?\n\n**Note**: This is **mod-controlled flag** so it is up to the mods of the subreddit to correctly mark their subreddit as NSFW",
|
||||
"type": "boolean"
|
||||
},
|
||||
"quarantine": {
|
||||
"description": "Is subreddit quarantined?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"stateDescription": {
|
||||
"description": "A friendly description of what this State is trying to parse",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"UserNoteCriteria": {
|
||||
"properties": {
|
||||
"count": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {CommentCheck} from "../Check/CommentCheck";
|
||||
import {
|
||||
cacheStats,
|
||||
createRetryHandler,
|
||||
determineNewResults, formatNumber,
|
||||
determineNewResults, findLastIndex, formatNumber,
|
||||
mergeArr, parseFromJsonOrYamlToObject, pollingInfo, resultsSummary, sleep, totalFromMapStats, triggeredIndicator,
|
||||
} from "../util";
|
||||
import {Poll} from "snoostorm";
|
||||
@@ -49,6 +49,7 @@ export interface runCheckOptions {
|
||||
checkNames?: string[],
|
||||
delayUntil?: number,
|
||||
dryRun?: boolean,
|
||||
refresh?: boolean,
|
||||
}
|
||||
|
||||
export interface CheckTask {
|
||||
@@ -85,7 +86,6 @@ export interface ManagerStats {
|
||||
actionsRunTotal: number
|
||||
actionsRunSinceStart: number,
|
||||
actionsRunSinceStartTotal: number
|
||||
actionedEvents: number
|
||||
cache: {
|
||||
provider: string,
|
||||
currentKeyCount: number,
|
||||
@@ -98,6 +98,12 @@ export interface ManagerStats {
|
||||
},
|
||||
}
|
||||
|
||||
interface QueuedIdentifier {
|
||||
id: string,
|
||||
shouldRefresh: boolean
|
||||
state: 'queued' | 'processing'
|
||||
}
|
||||
|
||||
export class Manager {
|
||||
subreddit: Subreddit;
|
||||
client: Snoowrap;
|
||||
@@ -121,6 +127,15 @@ export class Manager {
|
||||
globalDryRun?: boolean;
|
||||
emitter: EventEmitter = new EventEmitter();
|
||||
queue: QueueObject<CheckTask>;
|
||||
// firehose is used to ensure all activities from different polling streams are unique
|
||||
// that is -- if the same activities is in both modqueue and unmoderated we don't want to process the activity twice or use stale data
|
||||
//
|
||||
// so all activities get queued to firehose, it keeps track of items by id (using queuedItemsMeta)
|
||||
// and ensures that if any activities are ingested while they are ALSO currently queued or working then they are properly handled by either
|
||||
// 1) if queued, do not re-queue but instead tell worker to refresh before processing
|
||||
// 2) if currently processing then re-queue but also refresh before processing
|
||||
firehose: QueueObject<CheckTask>;
|
||||
queuedItemsMeta: QueuedIdentifier[] = [];
|
||||
globalMaxWorkers: number;
|
||||
subMaxWorkers?: number;
|
||||
|
||||
@@ -193,7 +208,6 @@ export class Manager {
|
||||
actionsRunTotal: totalFromMapStats(this.actionsRun),
|
||||
actionsRunSinceStart: this.actionsRunSinceStart,
|
||||
actionsRunSinceStartTotal: totalFromMapStats(this.actionsRunSinceStart),
|
||||
actionedEvents: this.actionedEvents.length,
|
||||
cache: {
|
||||
provider: 'none',
|
||||
currentKeyCount: 0,
|
||||
@@ -252,6 +266,7 @@ export class Manager {
|
||||
|
||||
this.queue = this.generateQueue(this.getMaxWorkers(this.globalMaxWorkers));
|
||||
this.queue.pause();
|
||||
this.firehose = this.generateFirehose();
|
||||
|
||||
this.eventsSampleInterval = setInterval((function(self) {
|
||||
return function() {
|
||||
@@ -312,6 +327,32 @@ export class Manager {
|
||||
return maxWorkers;
|
||||
}
|
||||
|
||||
protected generateFirehose() {
|
||||
return queue(async (task: CheckTask, cb) => {
|
||||
// items in queuedItemsMeta will be processing FIFO so earlier elements (by index) are older
|
||||
//
|
||||
// if we insert the same item again because it is currently being processed AND THEN we get the item AGAIN we only want to update the newest meta
|
||||
// so search the array backwards to get the neweset only
|
||||
const queuedItemIndex = findLastIndex(this.queuedItemsMeta, x => x.id === task.activity.id);
|
||||
if(queuedItemIndex !== -1) {
|
||||
const itemMeta = this.queuedItemsMeta[queuedItemIndex];
|
||||
let msg = `Item ${itemMeta.id} is already ${itemMeta.state}.`;
|
||||
if(itemMeta.state === 'queued') {
|
||||
this.logger.debug(`${msg} Flagging to refresh data before processing.`);
|
||||
this.queuedItemsMeta.splice(queuedItemIndex, 1, {...itemMeta, shouldRefresh: true});
|
||||
} else {
|
||||
this.logger.debug(`${msg} Re-queuing item but will also refresh data before processing.`);
|
||||
this.queuedItemsMeta.push({id: task.activity.id, shouldRefresh: true, state: 'queued'});
|
||||
this.queue.push(task);
|
||||
}
|
||||
} else {
|
||||
this.queuedItemsMeta.push({id: task.activity.id, shouldRefresh: false, state: 'queued'});
|
||||
this.queue.push(task);
|
||||
}
|
||||
}
|
||||
, 1);
|
||||
}
|
||||
|
||||
protected generateQueue(maxWorkers: number) {
|
||||
if (maxWorkers > 1) {
|
||||
this.logger.warn(`Setting max queue workers above 1 (specified: ${maxWorkers}) may have detrimental effects to log readability and api usage. Consult the documentation before using this advanced/experimental feature.`);
|
||||
@@ -322,7 +363,16 @@ export class Manager {
|
||||
this.logger.debug(`SOFT API LIMIT MODE: Delaying Event run by ${this.delayBy} seconds`);
|
||||
await sleep(this.delayBy * 1000);
|
||||
}
|
||||
await this.runChecks(task.checkType, task.activity, task.options);
|
||||
|
||||
const queuedItemIndex = this.queuedItemsMeta.findIndex(x => x.id === task.activity.id);
|
||||
try {
|
||||
const itemMeta = this.queuedItemsMeta[queuedItemIndex];
|
||||
this.queuedItemsMeta.splice(queuedItemIndex, 1, {...itemMeta, state: 'processing'});
|
||||
await this.runChecks(task.checkType, task.activity, {...task.options, refresh: itemMeta.shouldRefresh});
|
||||
} finally {
|
||||
// always remove item meta regardless of success or failure since we are done with it meow
|
||||
this.queuedItemsMeta.splice(queuedItemIndex, 1);
|
||||
}
|
||||
}
|
||||
, maxWorkers);
|
||||
q.error((err, task) => {
|
||||
@@ -518,8 +568,11 @@ export class Manager {
|
||||
checkNames = [],
|
||||
delayUntil,
|
||||
dryRun,
|
||||
refresh = false,
|
||||
} = options || {};
|
||||
|
||||
let wasRefreshed = false;
|
||||
|
||||
if (delayUntil !== undefined) {
|
||||
const created = dayjs.unix(item.created_utc);
|
||||
const diff = dayjs().diff(created, 's');
|
||||
@@ -528,8 +581,16 @@ export class Manager {
|
||||
await sleep(delayUntil - diff);
|
||||
// @ts-ignore
|
||||
item = await activity.refresh();
|
||||
wasRefreshed = true;
|
||||
}
|
||||
}
|
||||
// refresh signal from firehose if activity was ingested multiple times before processing or re-queued while processing
|
||||
// want to make sure we have the most recent data
|
||||
if(!wasRefreshed && refresh === true) {
|
||||
this.logger.verbose('Refreshed data (probably due to signal from firehose)');
|
||||
// @ts-ignore
|
||||
item = await activity.refresh();
|
||||
}
|
||||
|
||||
const startingApiLimit = this.client.ratelimitRemaining;
|
||||
|
||||
@@ -548,6 +609,7 @@ export class Manager {
|
||||
let totalRulesRun = 0;
|
||||
let runActions: ActionResult[] = [];
|
||||
let actionedEvent: ActionedEvent = {
|
||||
subreddit: this.subreddit.display_name_prefixed,
|
||||
activity: {
|
||||
peek: ePeek,
|
||||
link: item.permalink
|
||||
@@ -645,9 +707,7 @@ export class Manager {
|
||||
}
|
||||
actionedEvent.actionResults = runActions;
|
||||
if(triggered) {
|
||||
this.actionedEvents.unshift(actionedEvent);
|
||||
// save last 25 triggered events
|
||||
this.actionedEvents = this.actionedEvents.slice(0, 25);
|
||||
await this.resources.addActionedEvent(actionedEvent);
|
||||
}
|
||||
|
||||
this.logger.verbose(`Run Stats: Checks ${checksRun} | Rules => Total: ${totalRulesRun} Unique: ${allRuleResults.length} Cached: ${totalRulesRun - allRuleResults.length} Rolling Avg: ~${formatNumber(this.rulesUniqueRollingAvg)}/s | Actions ${actionsRun}`);
|
||||
@@ -749,7 +809,7 @@ export class Manager {
|
||||
checkType = 'Comment';
|
||||
}
|
||||
if (checkType !== undefined) {
|
||||
this.queue.push({checkType, activity: item, options: {delayUntil}})
|
||||
this.firehose.push({checkType, activity: item, options: {delayUntil}})
|
||||
}
|
||||
};
|
||||
|
||||
@@ -782,14 +842,19 @@ export class Manager {
|
||||
} else if (!this.validConfigLoaded) {
|
||||
this.logger.warn('Cannot start activity processing queue while manager has an invalid configuration');
|
||||
} else {
|
||||
if(this.queueState.state === STOPPED) {
|
||||
// extra precaution to make sure queue meta is cleared before starting queue
|
||||
this.queuedItemsMeta = [];
|
||||
}
|
||||
this.queue.resume();
|
||||
this.firehose.resume();
|
||||
this.logger.info(`Activity processing queue started RUNNING with ${this.queue.length()} queued activities`);
|
||||
this.queueState = {
|
||||
state: RUNNING,
|
||||
causedBy
|
||||
}
|
||||
if(!suppressNotification) {
|
||||
this.notificationManager.handle('runStateChanged', 'Queue Started', reason, causedBy)
|
||||
this.notificationManager.handle('runStateChanged', 'Queue Started', reason, causedBy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -851,7 +916,9 @@ export class Manager {
|
||||
this.logger.verbose(`Activity processing queue is stopping...waiting for ${this.queue.running()} activities to finish processing`);
|
||||
}
|
||||
this.logger.info(`Activity processing queue stopped by ${causedBy} and ${this.queue.length()} queued activities cleared (waited ${dayjs().diff(pauseWaitStart, 's')} seconds while activity processing finished)`);
|
||||
this.firehose.kill();
|
||||
this.queue.kill();
|
||||
this.queuedItemsMeta = [];
|
||||
}
|
||||
|
||||
this.queueState = {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Snoowrap, {RedditUser} from "snoowrap";
|
||||
import Snoowrap, {RedditUser, Subreddit} from "snoowrap";
|
||||
import objectHash from 'object-hash';
|
||||
import {
|
||||
activityIsDeleted, activityIsFiltered,
|
||||
@@ -8,24 +8,23 @@ import {
|
||||
getAuthorActivities,
|
||||
testAuthorCriteria
|
||||
} from "../Utils/SnoowrapUtils";
|
||||
import Subreddit from 'snoowrap/dist/objects/Subreddit';
|
||||
import winston, {Logger} from "winston";
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
asSubmission,
|
||||
buildCacheOptionsFromProvider, buildCachePrefix,
|
||||
cacheStats, createCacheManager,
|
||||
formatNumber, getActivityAuthorName,
|
||||
cacheStats, comparisonTextOp, createCacheManager,
|
||||
formatNumber, getActivityAuthorName, getActivitySubredditName, isStrongSubredditState,
|
||||
mergeArr,
|
||||
parseExternalUrl,
|
||||
parseWikiContext
|
||||
parseExternalUrl, parseGenericValueComparison,
|
||||
parseWikiContext, toStrongSubredditState
|
||||
} from "../util";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
BotInstanceConfig,
|
||||
CacheOptions, CommentState,
|
||||
Footer, OperatorConfig, ResourceStats, StrongCache, SubmissionState,
|
||||
CacheConfig, TTLConfig, TypedActivityStates, UserResultCache
|
||||
CacheConfig, TTLConfig, TypedActivityStates, UserResultCache, ActionedEvent, SubredditState, StrongSubredditState
|
||||
} from "../Common/interfaces";
|
||||
import UserNotes from "./UserNotes";
|
||||
import Mustache from "mustache";
|
||||
@@ -54,7 +53,8 @@ interface SubredditResourceOptions extends Footer {
|
||||
subreddit: Subreddit,
|
||||
logger: Logger;
|
||||
client: Snoowrap;
|
||||
prefix? :string;
|
||||
prefix?: string;
|
||||
actionedEventsMax: number;
|
||||
}
|
||||
|
||||
export interface SubredditResourceSetOptions extends CacheConfig, Footer {
|
||||
@@ -64,6 +64,7 @@ export class SubredditResources {
|
||||
//enabled!: boolean;
|
||||
protected useSubredditAuthorCache!: boolean;
|
||||
protected authorTTL: number | false = cacheTTLDefaults.authorTTL;
|
||||
protected subredditTTL: number | false = cacheTTLDefaults.subredditTTL;
|
||||
protected wikiTTL: number | false = cacheTTLDefaults.wikiTTL;
|
||||
protected submissionTTL: number | false = cacheTTLDefaults.submissionTTL;
|
||||
protected commentTTL: number | false = cacheTTLDefaults.commentTTL;
|
||||
@@ -79,6 +80,7 @@ export class SubredditResources {
|
||||
cacheSettingsHash?: string;
|
||||
pruneInterval?: any;
|
||||
prefix?: string
|
||||
actionedEventsMax: number;
|
||||
|
||||
stats: { cache: ResourceStats };
|
||||
|
||||
@@ -93,10 +95,12 @@ export class SubredditResources {
|
||||
filterCriteriaTTL,
|
||||
submissionTTL,
|
||||
commentTTL,
|
||||
subredditTTL,
|
||||
},
|
||||
cache,
|
||||
prefix,
|
||||
cacheType,
|
||||
actionedEventsMax,
|
||||
cacheSettingsHash,
|
||||
client,
|
||||
} = options || {};
|
||||
@@ -106,9 +110,11 @@ export class SubredditResources {
|
||||
this.prefix = prefix;
|
||||
this.client = client;
|
||||
this.cacheType = cacheType;
|
||||
this.actionedEventsMax = actionedEventsMax;
|
||||
this.authorTTL = authorTTL === true ? 0 : authorTTL;
|
||||
this.submissionTTL = submissionTTL === true ? 0 : submissionTTL;
|
||||
this.commentTTL = commentTTL === true ? 0 : commentTTL;
|
||||
this.subredditTTL = subredditTTL === true ? 0 : subredditTTL;
|
||||
this.wikiTTL = wikiTTL === true ? 0 : wikiTTL;
|
||||
this.filterCriteriaTTL = filterCriteriaTTL === true ? 0 : filterCriteriaTTL;
|
||||
this.subreddit = subreddit;
|
||||
@@ -213,6 +219,16 @@ export class SubredditResources {
|
||||
this.logger = logger.child({labels: ['Resource Cache']}, mergeArr);
|
||||
}
|
||||
|
||||
async getActionedEvents(): Promise<ActionedEvent[]> {
|
||||
return await this.cache.wrap(`actionedEvents-${this.subreddit.display_name}`, () => []);
|
||||
}
|
||||
|
||||
async addActionedEvent(ae: ActionedEvent) {
|
||||
const events = await this.cache.wrap(`actionedEvents-${this.subreddit.display_name}`, () => []) as ActionedEvent[];
|
||||
events.unshift(ae);
|
||||
await this.cache.set(`actionedEvents-${this.subreddit.display_name}`, events.slice(0, this.actionedEventsMax), {ttl: 0});
|
||||
}
|
||||
|
||||
async getActivity(item: Submission | Comment) {
|
||||
try {
|
||||
let hash = '';
|
||||
@@ -256,6 +272,40 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
async getSubreddit(item: Submission | Comment) {
|
||||
try {
|
||||
let hash = '';
|
||||
if (this.subredditTTL !== false) {
|
||||
hash = `sub-${getActivitySubredditName(item)}`;
|
||||
await this.stats.cache.subreddit.identifierRequestCount.set(hash, (await this.stats.cache.subreddit.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
this.stats.cache.subreddit.requestTimestamps.push(Date.now());
|
||||
this.stats.cache.subreddit.requests++;
|
||||
const cachedSubreddit = await this.cache.get(hash);
|
||||
if (cachedSubreddit !== undefined && cachedSubreddit !== null) {
|
||||
this.logger.debug(`Cache Hit: Subreddit ${item.subreddit.display_name}`);
|
||||
// @ts-ignore
|
||||
return cachedSubreddit as Subreddit;
|
||||
}
|
||||
// @ts-ignore
|
||||
const subreddit = await this.client.getSubreddit(getActivitySubredditName(item)).fetch() as Subreddit;
|
||||
this.stats.cache.subreddit.miss++;
|
||||
// @ts-ignore
|
||||
await this.cache.set(hash, subreddit, {ttl: this.subredditTTL});
|
||||
// @ts-ignore
|
||||
return subreddit as Subreddit;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
let subreddit = await this.client.getSubreddit(getActivitySubredditName(item));
|
||||
|
||||
return subreddit as Subreddit;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Error while trying to fetch a cached activity', err);
|
||||
throw err.logged;
|
||||
}
|
||||
}
|
||||
|
||||
async getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
|
||||
const userName = getActivityAuthorName(user);
|
||||
if (this.authorTTL !== false) {
|
||||
@@ -377,6 +427,46 @@ export class SubredditResources {
|
||||
return wikiContent;
|
||||
}
|
||||
|
||||
async testSubredditCriteria(item: (Comment | Submission), state: SubredditState | StrongSubredditState) {
|
||||
if(Object.keys(state).length === 0) {
|
||||
return true;
|
||||
}
|
||||
// optimize for name-only criteria checks
|
||||
// -- we don't need to store cache results for this since we know subreddit name is always available from item (no request required)
|
||||
const critCount = Object.entries(state).filter(([key, val]) => {
|
||||
return val !== undefined && !['name','stateDescription'].includes(key);
|
||||
}).length;
|
||||
if(critCount === 0) {
|
||||
const subName = getActivitySubredditName(item);
|
||||
return await this.isSubreddit({display_name: subName} as Subreddit, state, this.logger);
|
||||
}
|
||||
|
||||
if (this.filterCriteriaTTL !== false) {
|
||||
try {
|
||||
const hash = `subredditCrit-${getActivitySubredditName(item)}-${objectHash.sha1(state)}`;
|
||||
await this.stats.cache.subredditCrit.identifierRequestCount.set(hash, (await this.stats.cache.subredditCrit.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
this.stats.cache.subredditCrit.requestTimestamps.push(Date.now());
|
||||
this.stats.cache.subredditCrit.requests++;
|
||||
const cachedItem = await this.cache.get(hash);
|
||||
if (cachedItem !== undefined && cachedItem !== null) {
|
||||
this.logger.debug(`Cache Hit: Subreddit Check on ${getActivitySubredditName(item)} (Hash ${hash})`);
|
||||
return cachedItem as boolean;
|
||||
}
|
||||
const itemResult = await this.isSubreddit(await this.getSubreddit(item), state, this.logger);
|
||||
this.stats.cache.subredditCrit.miss++;
|
||||
await this.cache.set(hash, itemResult, {ttl: this.filterCriteriaTTL});
|
||||
return itemResult;
|
||||
} catch (err) {
|
||||
if (err.logged !== true) {
|
||||
this.logger.error('Error occurred while testing subreddit criteria', err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
return await this.isSubreddit(await this.getSubreddit(item), state, this.logger);
|
||||
}
|
||||
|
||||
async testAuthorCriteria(item: (Comment | Submission), authorOpts: AuthorCriteria, include = true) {
|
||||
if (this.filterCriteriaTTL !== false) {
|
||||
// in the criteria check we only actually use the `item` to get the author flair
|
||||
@@ -406,10 +496,10 @@ export class SubredditResources {
|
||||
return await testAuthorCriteria(item, authorOpts, include, this.userNotes);
|
||||
}
|
||||
|
||||
async testItemCriteria(i: (Comment | Submission), s: TypedActivityStates) {
|
||||
async testItemCriteria(i: (Comment | Submission), activityStates: TypedActivityStates) {
|
||||
if (this.filterCriteriaTTL !== false) {
|
||||
let item = i;
|
||||
let states = s;
|
||||
let states = activityStates;
|
||||
// optimize for submission only checks on comment item
|
||||
if (item instanceof Comment && states.length === 1 && Object.keys(states[0]).length === 1 && (states[0] as CommentState).submissionState !== undefined) {
|
||||
// @ts-ignore
|
||||
@@ -440,7 +530,50 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
|
||||
return await this.isItem(i, s, this.logger);
|
||||
return await this.isItem(i, activityStates, this.logger);
|
||||
}
|
||||
|
||||
async isSubreddit (subreddit: Subreddit, stateCriteria: SubredditState | StrongSubredditState, logger: Logger) {
|
||||
delete stateCriteria.stateDescription;
|
||||
|
||||
if (Object.keys(stateCriteria).length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const crit = isStrongSubredditState(stateCriteria) ? stateCriteria : toStrongSubredditState(stateCriteria, {defaultFlags: 'i'});
|
||||
|
||||
const log = logger.child({leaf: 'Subreddit Check'}, mergeArr);
|
||||
|
||||
return await (async () => {
|
||||
for (const k of Object.keys(crit)) {
|
||||
// @ts-ignore
|
||||
if (crit[k] !== undefined) {
|
||||
switch (k) {
|
||||
case 'name':
|
||||
const nameReg = crit[k] as RegExp;
|
||||
if(!nameReg.test(subreddit.display_name)) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
// @ts-ignore
|
||||
if (crit[k] !== undefined) {
|
||||
// @ts-ignore
|
||||
if (crit[k] !== subreddit[k]) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${subreddit[k]}`)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
log.warn(`Tried to test for Subreddit property '${k}' but it did not exist`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug(`Passed: ${JSON.stringify(stateCriteria)}`);
|
||||
return true;
|
||||
})() as boolean;
|
||||
}
|
||||
|
||||
async isItem (item: Submission | Comment, stateCriteria: TypedActivityStates, logger: Logger) {
|
||||
@@ -472,6 +605,22 @@ export class SubredditResources {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'score':
|
||||
const scoreCompare = parseGenericValueComparison(crit[k] as string);
|
||||
if(!comparisonTextOp(item.score, scoreCompare.operator, scoreCompare.value)) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item.score}`)
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'reports':
|
||||
const reportCompare = parseGenericValueComparison(crit[k] as string);
|
||||
if(!comparisonTextOp(item.num_reports, reportCompare.operator, reportCompare.value)) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item.num_reports}`)
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'removed':
|
||||
const removed = activityIsRemoved(item);
|
||||
if (removed !== crit['removed']) {
|
||||
@@ -586,6 +735,8 @@ export class BotResourcesManager {
|
||||
cacheType: string = 'none';
|
||||
cacheHash: string;
|
||||
ttlDefaults: Required<TTLConfig>;
|
||||
actionedEventsMaxDefault?: number;
|
||||
actionedEventsDefault: number;
|
||||
pruneInterval: any;
|
||||
|
||||
constructor(config: BotInstanceConfig) {
|
||||
@@ -596,20 +747,26 @@ export class BotResourcesManager {
|
||||
wikiTTL,
|
||||
commentTTL,
|
||||
submissionTTL,
|
||||
subredditTTL,
|
||||
filterCriteriaTTL,
|
||||
provider,
|
||||
actionedEventsMax,
|
||||
actionedEventsDefault,
|
||||
},
|
||||
name,
|
||||
credentials,
|
||||
caching,
|
||||
} = config;
|
||||
caching.provider.prefix = buildCachePrefix([caching.provider.prefix, 'SHARED']);
|
||||
this.cacheHash = objectHash.sha1(caching);
|
||||
const {actionedEventsMax: eMax, actionedEventsDefault: eDef, ...relevantCacheSettings} = caching;
|
||||
this.cacheHash = objectHash.sha1(relevantCacheSettings);
|
||||
this.defaultCacheConfig = caching;
|
||||
this.ttlDefaults = {authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL};
|
||||
this.ttlDefaults = {authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL, subredditTTL};
|
||||
|
||||
const options = provider;
|
||||
this.cacheType = options.store;
|
||||
this.actionedEventsMaxDefault = actionedEventsMax;
|
||||
this.actionedEventsDefault = actionedEventsDefault;
|
||||
this.defaultCache = createCacheManager(options);
|
||||
if (this.cacheType === 'memory') {
|
||||
const min = Math.min(...([this.ttlDefaults.wikiTTL, this.ttlDefaults.authorTTL, this.ttlDefaults.userNotesTTL].filter(x => typeof x === 'number' && x !== 0) as number[]));
|
||||
@@ -644,11 +801,12 @@ export class BotResourcesManager {
|
||||
cacheSettingsHash: hash,
|
||||
ttl: this.ttlDefaults,
|
||||
prefix: this.defaultCacheConfig.provider.prefix,
|
||||
actionedEventsMax: this.actionedEventsMaxDefault !== undefined ? Math.min(this.actionedEventsDefault, this.actionedEventsMaxDefault) : this.actionedEventsDefault,
|
||||
...init,
|
||||
};
|
||||
|
||||
if(caching !== undefined) {
|
||||
const {provider = this.defaultCacheConfig.provider, ...rest} = caching;
|
||||
const {provider = this.defaultCacheConfig.provider, actionedEventsMax = this.actionedEventsDefault, ...rest} = caching;
|
||||
let cacheConfig = {
|
||||
provider: buildCacheOptionsFromProvider(provider),
|
||||
ttl: {
|
||||
@@ -663,8 +821,10 @@ export class BotResourcesManager {
|
||||
const defaultPrefix = trueProvider.prefix;
|
||||
const subPrefix = defaultPrefix === this.defaultCacheConfig.provider.prefix ? buildCachePrefix([(defaultPrefix !== undefined ? defaultPrefix.replace('SHARED', '') : defaultPrefix), subName]) : trueProvider.prefix;
|
||||
trueProvider.prefix = subPrefix;
|
||||
const eventsMax = this.actionedEventsMaxDefault !== undefined ? Math.min(actionedEventsMax, this.actionedEventsMaxDefault) : actionedEventsMax;
|
||||
opts = {
|
||||
cache: createCacheManager(trueProvider),
|
||||
actionedEventsMax: eventsMax,
|
||||
cacheType: trueProvider.store,
|
||||
cacheSettingsHash: hash,
|
||||
prefix: subPrefix,
|
||||
|
||||
@@ -13,10 +13,16 @@ import {
|
||||
TypedActivityStates
|
||||
} from "../Common/interfaces";
|
||||
import {
|
||||
compareDurationValue, comparisonTextOp,
|
||||
compareDurationValue,
|
||||
comparisonTextOp,
|
||||
isActivityWindowCriteria,
|
||||
normalizeName, parseDuration,
|
||||
parseDurationComparison, parseGenericValueComparison, parseGenericValueOrPercentComparison, parseSubredditName,
|
||||
normalizeName,
|
||||
parseDuration,
|
||||
parseDurationComparison,
|
||||
parseGenericValueComparison,
|
||||
parseGenericValueOrPercentComparison,
|
||||
parseRuleResultsToMarkdownSummary,
|
||||
parseSubredditName,
|
||||
truncateStringToLength
|
||||
} from "../util";
|
||||
import UserNotes from "../Subreddit/UserNotes";
|
||||
@@ -305,7 +311,7 @@ export const renderContent = async (template: string, data: (Submission | Commen
|
||||
};
|
||||
}, {});
|
||||
|
||||
const view = {item: templateData, rules: normalizedRuleResults};
|
||||
const view = {item: templateData, ruleSummary: parseRuleResultsToMarkdownSummary(ruleResults), rules: normalizedRuleResults};
|
||||
const rendered = Mustache.render(template, view) as string;
|
||||
return he.decode(rendered);
|
||||
}
|
||||
@@ -607,6 +613,9 @@ export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain =
|
||||
if (displayDomain === '') {
|
||||
displayDomain = domain;
|
||||
}
|
||||
if(domainIdents.length === 0 && domain !== '') {
|
||||
domainIdents.push(domain);
|
||||
}
|
||||
|
||||
return {display: displayDomain, domain, aliases: domainIdents, provider, mediaType};
|
||||
}
|
||||
|
||||
@@ -847,7 +847,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
return res.render('events', {
|
||||
data: resp.map((x) => {
|
||||
const {timestamp, activity: {peek, link}, ruleResults = [], actionResults = [], ...rest} = x;
|
||||
const time = dayjs(timestamp).local().format();
|
||||
const time = dayjs(timestamp).local().format('YY-MM-DD HH:mm:ss z');
|
||||
const formattedPeek = Autolinker.link(peek, {
|
||||
email: false,
|
||||
phone: false,
|
||||
@@ -858,11 +858,11 @@ const webClient = async (options: OperatorConfig) => {
|
||||
});
|
||||
const formattedRuleResults = ruleResults.map((y: any) => {
|
||||
const {triggered, result, ...restY} = y;
|
||||
let t = 'Not Triggered';
|
||||
let t = triggeredIndicator(false);
|
||||
if(triggered === null) {
|
||||
t = 'Skipped';
|
||||
} else if(triggered === true) {
|
||||
t = 'Triggered';
|
||||
t = triggeredIndicator(true);
|
||||
}
|
||||
return {
|
||||
...restY,
|
||||
@@ -876,7 +876,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
if(!run) {
|
||||
res = `Not Run - ${runReason === undefined ? '(No Reason)' : runReason}`;
|
||||
} else {
|
||||
res = `Success: ${triggeredIndicator(success)}${result !== undefined ? ` - ${result}` : ''}`;
|
||||
res = `${triggeredIndicator(success)}${result !== undefined ? ` - ${result}` : ''}`;
|
||||
}
|
||||
return {
|
||||
...restA,
|
||||
@@ -895,7 +895,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
actionResults: formattedActionResults
|
||||
}
|
||||
}),
|
||||
title: `${subreddit} Actioned Events`
|
||||
title: `${subreddit !== undefined ? `${subreddit} ` : ''}Actioned Events`
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,6 @@ const managerStats: ManagerStats = {
|
||||
rulesRunTotal: 0,
|
||||
rulesTriggeredSinceStartTotal: 0,
|
||||
rulesTriggeredTotal: 0,
|
||||
actionedEvents: 0,
|
||||
};
|
||||
const botStats: BotStats = {
|
||||
apiAvg: '-',
|
||||
|
||||
@@ -37,16 +37,20 @@ export const subredditRoute = (required = true) => async (req: Request, res: Res
|
||||
const bot = req.serverBot;
|
||||
|
||||
const {subreddit} = req.query as any;
|
||||
const {name: userName, realManagers = [], isOperator} = req.user as Express.User;
|
||||
if (!isOperator && !realManagers.includes(subreddit)) {
|
||||
return res.status(400).send('Cannot access route for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
const manager = bot.subManagers.find(x => x.displayLabel === subreddit);
|
||||
if (manager === undefined) {
|
||||
return res.status(400).send('Cannot access route for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
if(subreddit === undefined && required === false) {
|
||||
next();
|
||||
} else {
|
||||
const {name: userName, realManagers = [], isOperator} = req.user as Express.User;
|
||||
if (!isOperator && !realManagers.includes(subreddit)) {
|
||||
return res.status(400).send('Cannot access route for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
const manager = bot.subManagers.find(x => x.displayLabel === subreddit);
|
||||
if (manager === undefined) {
|
||||
return res.status(400).send('Cannot access route for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
|
||||
req.manager = manager;
|
||||
req.manager = manager;
|
||||
|
||||
next();
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ const action = async (req: express.Request, res: express.Response) => {
|
||||
if (type === 'unmoderated') {
|
||||
const activities = await manager.subreddit.getUnmoderated({limit: 100});
|
||||
for (const a of activities.reverse()) {
|
||||
await manager.queue.push({
|
||||
await manager.firehose.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
});
|
||||
@@ -75,7 +75,7 @@ const action = async (req: express.Request, res: express.Response) => {
|
||||
} else {
|
||||
const activities = await manager.subreddit.getModqueue({limit: 100});
|
||||
for (const a of activities.reverse()) {
|
||||
await manager.queue.push({
|
||||
await manager.firehose.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ import winston from 'winston';
|
||||
import {COMMENT_URL_ID, parseLinkIdentifier, SUBMISSION_URL_ID} from "../../../../../util";
|
||||
import {booleanMiddle} from "../../../../Common/middleware";
|
||||
import {Manager} from "../../../../../Subreddit/Manager";
|
||||
import {ActionedEvent} from "../../../../../Common/interfaces";
|
||||
|
||||
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
|
||||
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
|
||||
@@ -21,11 +22,30 @@ export const configRoute = [authUserCheck(), botRoute(), subredditRoute(), confi
|
||||
|
||||
const actionedEvents = async (req: Request, res: Response) => {
|
||||
|
||||
const manager = req.manager as Manager;
|
||||
let managers: Manager[] = [];
|
||||
const manager = req.manager as Manager | undefined;
|
||||
if(manager !== undefined) {
|
||||
managers.push(manager);
|
||||
} else {
|
||||
for(const manager of req.serverBot.subManagers) {
|
||||
if((req.user?.realManagers as string[]).includes(manager.displayLabel)) {
|
||||
managers.push(manager);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res.json(manager.actionedEvents);
|
||||
let events: ActionedEvent[] = [];
|
||||
for(const m of managers) {
|
||||
if(m.resources !== undefined) {
|
||||
events = events.concat(await m.resources.getActionedEvents());
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => b.timestamp - a.timestamp);
|
||||
|
||||
return res.json(events);
|
||||
};
|
||||
export const actionedEventsRoute = [authUserCheck(), botRoute(), subredditRoute(), actionedEvents];
|
||||
export const actionedEventsRoute = [authUserCheck(), botRoute(), subredditRoute(false), actionedEvents];
|
||||
|
||||
const action = async (req: Request, res: Response) => {
|
||||
const bot = req.serverBot;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="dark:text-white mb-3 pl-2">
|
||||
Schema <a href="/config?schema=subreddit" id="subredditSchemaType">Subreddit</a> / <a href="/config?schema=operator" id="operatorSchemaType">Operator</a> |
|
||||
<span class="has-tooltip">
|
||||
<span style="z-index:999; margin-top: 30px;" class='tooltip rounded shadow-lg p-3 bg-gray-100 text-black space-y-2'>
|
||||
<div>Copy + paste your configuration here to get:</div>
|
||||
@@ -39,7 +40,6 @@
|
||||
</ul>
|
||||
<div>When done editing hit Ctrl+A (Command+A on macOS) to select all text, then copy + paste back into your wiki/file</div>
|
||||
</span>
|
||||
<span id="schemaType"></span> |
|
||||
<span class="cursor-help">
|
||||
How To Use
|
||||
<span>
|
||||
@@ -55,7 +55,7 @@
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
| <a id="schemaOpen" href="">Open With Operator Schema</a>
|
||||
| <input id="configUrl" class="text-black placeholder-gray-500 rounded mx-2" style="min-width:400px;" placeholder="URL of a config to load"/> <a href="#" id="loadConfig">Load</a>
|
||||
<div id="error" class="font-semibold"></div>
|
||||
</div>
|
||||
<div style="min-height: 80vh" id="editor"></div>
|
||||
@@ -104,22 +104,22 @@
|
||||
var searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
let schemaType;
|
||||
let schemaFile;
|
||||
if(searchParams.get('schema') === 'operator') {
|
||||
schemaType = 'OperatorConfig.json';
|
||||
schemaType = 'operator';
|
||||
schemaFile = 'OperatorConfig.json';
|
||||
preamble.push('// automatic validation of your OPERATOR configuration');
|
||||
document.querySelector('#schemaTypeList').innerHTML = 'automatic validation of your OPERATOR configuration (yellow squiggly)';
|
||||
document.querySelector('#schemaType').innerHTML = 'Operator Configuration';
|
||||
document.querySelector('#schemaOpen').href = '/config?schema=subreddit';
|
||||
document.querySelector('#schemaOpen').innerHTML = 'Open with Subreddit Schema';
|
||||
document.querySelector('#operatorSchemaType').classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
} else {
|
||||
schemaType = 'App.json';
|
||||
schemaType = 'subreddit';
|
||||
schemaFile = 'App.json';
|
||||
preamble.push('// automatic validation of your SUBREDDIT configuration');
|
||||
document.querySelector('#schemaTypeList').innerHTML = 'automatic validation of your SUBREDDIT configuration (yellow squiggly)'
|
||||
document.querySelector('#schemaType').innerHTML = 'Subreddit Configuration';
|
||||
document.querySelector('#schemaOpen').href = '/config?schema=operator';
|
||||
document.querySelector('#subredditSchemaType').classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
}
|
||||
|
||||
const schemaUri = `${document.location.origin}/schemas/${schemaType}`;
|
||||
const schemaUri = `${document.location.origin}/schemas/${schemaFile}`;
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
const modelUri = monaco.Uri.parse("a://b/foo.json");
|
||||
@@ -135,15 +135,44 @@
|
||||
schema: schemaData
|
||||
}]
|
||||
});
|
||||
if(searchParams.get('subreddit') !== null) {
|
||||
fetch(`${document.location.origin}/config/content${document.location.search}`).then((resp) => {
|
||||
var model = monaco.editor.createModel(preamble.join('\r\n'), "json", modelUri);
|
||||
|
||||
document.querySelector('#loadConfig').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const newUrl = document.querySelector('#configUrl').value;
|
||||
fetch(newUrl).then((resp) => {
|
||||
if(!resp.ok) {
|
||||
resp.text().then(data => {
|
||||
document.querySelector('#error').innerHTML = `Error occurred while fetching configuration => ${data}`
|
||||
});
|
||||
} else {
|
||||
var sp = new URLSearchParams();
|
||||
sp.append('schema', schemaType);
|
||||
sp.append('url', newUrl);
|
||||
history.pushState(null, '', `${window.location.pathname}?${sp.toString()}`);
|
||||
resp.text().then(data => {
|
||||
//model = monaco.editor.createModel(data, "json", modelUri);
|
||||
model.setValue(data);
|
||||
})
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let dlUrl = searchParams.get('url');
|
||||
if(dlUrl === null && searchParams.get('subreddit') !== null) {
|
||||
dlUrl = `${document.location.origin}/config/content${document.location.search}`
|
||||
}
|
||||
if(dlUrl !== null) {
|
||||
document.querySelector('#configUrl').value = dlUrl;
|
||||
fetch(dlUrl).then((resp) => {
|
||||
if(!resp.ok) {
|
||||
resp.text().then(data => {
|
||||
document.querySelector('#error').innerHTML = `Error occurred while fetching configuration => ${data}`
|
||||
});
|
||||
} else {
|
||||
resp.text().then(data => {
|
||||
var model = monaco.editor.createModel(data, "json", modelUri);
|
||||
model.setValue(data);
|
||||
//model = monaco.editor.createModel(data, "json", modelUri);
|
||||
var editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
model,
|
||||
theme: 'vs-dark',
|
||||
@@ -154,9 +183,8 @@
|
||||
editor;
|
||||
})
|
||||
}
|
||||
})
|
||||
});
|
||||
} else {
|
||||
var model = monaco.editor.createModel(preamble.join('\r\n'), "json", modelUri);
|
||||
var editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
model,
|
||||
theme: 'vs-dark',
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="user-select: none;" class="">
|
||||
<body>
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/title') %>
|
||||
@@ -38,7 +38,7 @@
|
||||
<span class="peek"><%- eRes.activity.peek %></span><a target="_blank" href="https://reddit.com<%= eRes.activity.link%>">(Link)</a>
|
||||
</div>
|
||||
<div class="flex items-center flex-end">
|
||||
<%= eRes.timestamp %>
|
||||
<%= eRes.subreddit %> @ <%= eRes.timestamp %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -373,11 +373,7 @@
|
||||
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
|
||||
<span><%= data.stats.checksTriggeredTotal %></span> Triggered / <span><%= data.stats.checksRunTotal %></span> Run
|
||||
</span>
|
||||
<% if (data.name !== 'All') { %>
|
||||
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>" class="underline" style="text-decoration-style: dotted"><%= data.stats.checksTriggeredTotal %> T</a>
|
||||
<% } else { %>
|
||||
<%= data.stats.checksTriggeredTotal %> T
|
||||
<% } %>/ <span><%= data.stats.checksRunTotal %></span> R</span>
|
||||
<span><%= data.stats.checksTriggeredTotal %> T / <span><%= data.stats.checksRunTotal %></span> R</span>
|
||||
</span>
|
||||
|
||||
<label>Rules</label>
|
||||
@@ -389,7 +385,11 @@
|
||||
</span>
|
||||
|
||||
<label>Actions</label>
|
||||
<span><%= data.stats.actionsRunTotal %> Run</span>
|
||||
<% if (data.name !== 'All') { %>
|
||||
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>" class="underline" style="text-decoration-style: dotted"><%= data.stats.actionsRunTotal %> Run</a>
|
||||
<% } else { %>
|
||||
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>" class="underline" style="text-decoration-style: dotted"><%= data.stats.actionsRunTotal %> Run</a>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import winston from 'winston';
|
||||
import 'winston-daily-rotate-file';
|
||||
import dayjs from 'dayjs';
|
||||
import utc from 'dayjs/plugin/utc.js';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||
import tz from 'dayjs/plugin/timezone';
|
||||
import dduration from 'dayjs/plugin/duration.js';
|
||||
import relTime from 'dayjs/plugin/relativeTime.js';
|
||||
import sameafter from 'dayjs/plugin/isSameOrAfter.js';
|
||||
@@ -31,6 +33,8 @@ dayjs.extend(dduration);
|
||||
dayjs.extend(relTime);
|
||||
dayjs.extend(sameafter);
|
||||
dayjs.extend(samebefore);
|
||||
dayjs.extend(tz);
|
||||
dayjs.extend(advancedFormat);
|
||||
|
||||
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
|
||||
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
|
||||
@@ -182,7 +186,7 @@ const program = new Command();
|
||||
for(const manager of b.subManagers) {
|
||||
const activities = await manager.subreddit.getUnmoderated();
|
||||
for (const a of activities.reverse()) {
|
||||
manager.queue.push({
|
||||
manager.firehose.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
options: {checkNames: checks}
|
||||
|
||||
124
src/util.ts
124
src/util.ts
@@ -1,7 +1,7 @@
|
||||
import winston, {Logger} from "winston";
|
||||
import jsonStringify from 'safe-stable-stringify';
|
||||
import dayjs, {Dayjs, OpUnitType} from 'dayjs';
|
||||
import {isRuleSetResult, RulePremise, RuleResult, RuleSetResult} from "./Rule";
|
||||
import {FormattedRuleResult, isRuleSetResult, RulePremise, RuleResult, RuleSetResult} from "./Rule";
|
||||
import deepEqual from "fast-deep-equal";
|
||||
import {Duration} from 'dayjs/plugin/duration.js';
|
||||
import Ajv from "ajv";
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
ActivityWindowCriteria, CacheOptions, CacheProvider,
|
||||
DurationComparison,
|
||||
GenericComparison, LogInfo, NamedGroup,
|
||||
PollingOptionsStrong, RegExResult, ResourceStats,
|
||||
StringOperator
|
||||
PollingOptionsStrong, RedditEntity, RedditEntityType, RegExResult, ResourceStats,
|
||||
StringOperator, StrongSubredditState, SubredditState
|
||||
} from "./Common/interfaces";
|
||||
import JSON5 from "json5";
|
||||
import yaml, {JSON_SCHEMA} from "js-yaml";
|
||||
@@ -29,6 +29,9 @@ import Autolinker from 'autolinker';
|
||||
import {create as createMemoryStore} from './Utils/memoryStore';
|
||||
import {MESSAGE} from "triple-beam";
|
||||
import {RedditUser} from "snoowrap/dist/objects";
|
||||
import reRegExp from '@stdlib/regexp-regexp';
|
||||
|
||||
const ReReg = reRegExp();
|
||||
|
||||
const {format} = winston;
|
||||
const {combine, printf, timestamp, label, splat, errors} = format;
|
||||
@@ -570,6 +573,24 @@ export const parseSubredditName = (val:string): string => {
|
||||
return matches[1] as string;
|
||||
}
|
||||
|
||||
export const REDDIT_ENTITY_REGEX: RegExp = /^\s*(?<entityType>\/[ru]\/|[ru]\/)*(?<name>\w+)*\s*$/;
|
||||
export const REDDIT_ENTITY_REGEX_URL = 'https://regexr.com/65r9b';
|
||||
export const parseRedditEntity = (val:string): RedditEntity => {
|
||||
const matches = val.match(REDDIT_ENTITY_REGEX);
|
||||
if (matches === null) {
|
||||
throw new InvalidRegexError(REDDIT_ENTITY_REGEX, val, REDDIT_ENTITY_REGEX_URL)
|
||||
}
|
||||
const groups = matches.groups as any;
|
||||
let eType: RedditEntityType = 'user';
|
||||
if(groups.entityType !== undefined && typeof groups.entityType === 'string' && groups.entityType.includes('r')) {
|
||||
eType = 'subreddit';
|
||||
}
|
||||
return {
|
||||
name: groups.name,
|
||||
type: eType,
|
||||
}
|
||||
}
|
||||
|
||||
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*$/;
|
||||
@@ -828,9 +849,19 @@ export const isExternalUrlSubmission = (act: Comment | Submission): boolean => {
|
||||
return asSubmission(act) && !act.is_self && !isRedditMedia(act);
|
||||
}
|
||||
|
||||
export const parseRegex = (r: string | RegExp, val: string, flags?: string): RegExResult => {
|
||||
export const parseStringToRegex = (val: string, defaultFlags?: string): RegExp | undefined => {
|
||||
const result = ReReg.exec(val);
|
||||
if (result === null) {
|
||||
return undefined;
|
||||
}
|
||||
// index 0 => full string
|
||||
// index 1 => regex without flags and forward slashes
|
||||
// index 2 => flags
|
||||
const flags = result[2] === '' ? (defaultFlags || '') : result[2];
|
||||
return new RegExp(result[1], flags);
|
||||
}
|
||||
|
||||
const reg = r instanceof RegExp ? r : new RegExp(r, flags);
|
||||
export const parseRegex = (reg: RegExp, val: string): RegExResult => {
|
||||
|
||||
if(reg.global) {
|
||||
const g = Array.from(val.matchAll(reg));
|
||||
@@ -856,6 +887,46 @@ export const parseRegex = (r: string | RegExp, val: string, flags?: string): Reg
|
||||
}
|
||||
}
|
||||
|
||||
export const isStrongSubredditState = (value: SubredditState | StrongSubredditState) => {
|
||||
return value.name === undefined || value.name instanceof RegExp;
|
||||
}
|
||||
|
||||
export const asStrongSubredditState = (value: any): value is StrongSubredditState => {
|
||||
return isStrongSubredditState(value);
|
||||
}
|
||||
|
||||
export interface StrongSubredditStateOptions {
|
||||
defaultFlags?: string
|
||||
generateDescription?: boolean
|
||||
}
|
||||
|
||||
export const toStrongSubredditState = (s: SubredditState, opts?: StrongSubredditStateOptions): StrongSubredditState => {
|
||||
const {defaultFlags, generateDescription = false} = opts || {};
|
||||
const {name: nameVal, stateDescription} = s;
|
||||
|
||||
let nameReg: RegExp | undefined;
|
||||
if (nameVal !== undefined) {
|
||||
if (!(nameVal instanceof RegExp)) {
|
||||
nameReg = parseStringToRegex(nameVal, defaultFlags);
|
||||
if (nameReg === undefined) {
|
||||
nameReg = new RegExp(parseSubredditName(nameVal), defaultFlags);
|
||||
}
|
||||
} else {
|
||||
nameReg = nameVal;
|
||||
}
|
||||
}
|
||||
const strongState = {
|
||||
...s,
|
||||
name: nameReg
|
||||
};
|
||||
|
||||
if (generateDescription && stateDescription === undefined) {
|
||||
strongState.stateDescription = objectToStringSummary(strongState);
|
||||
}
|
||||
|
||||
return strongState;
|
||||
}
|
||||
|
||||
export async function readConfigFile(path: string, opts: any) {
|
||||
const {log, throwOnNotFound = true} = opts;
|
||||
try {
|
||||
@@ -932,10 +1003,12 @@ export const cacheStats = (): ResourceStats => {
|
||||
author: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
authorCrit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
itemCrit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
subredditCrit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
content: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
userNotes: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
submission: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
comment: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
subreddit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
||||
commentCheck: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0}
|
||||
};
|
||||
}
|
||||
@@ -1040,3 +1113,44 @@ export const buildCachePrefix = (parts: any[]): string => {
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
|
||||
export const objectToStringSummary = (obj: object): string => {
|
||||
const parts = [];
|
||||
for(const [key, val] of Object.entries(obj)) {
|
||||
parts.push(`${key}: ${val}`);
|
||||
}
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the index of the last element in the array where predicate is true, and -1
|
||||
* otherwise.
|
||||
* @param array The source array to search in
|
||||
* @param predicate find calls predicate once for each element of the array, in descending
|
||||
* order, until it finds one where predicate returns true. If such an element is found,
|
||||
* findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
|
||||
*
|
||||
* @see https://stackoverflow.com/a/53187807/1469797
|
||||
*/
|
||||
export function findLastIndex<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): number {
|
||||
let l = array.length;
|
||||
while (l--) {
|
||||
if (predicate(array[l], l, array))
|
||||
return l;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
export const parseRuleResultsToMarkdownSummary = (ruleResults: RuleResult[]): string => {
|
||||
const results = ruleResults.map((y: any) => {
|
||||
const {triggered, result, name, ...restY} = y;
|
||||
let t = triggeredIndicator(false);
|
||||
if(triggered === null) {
|
||||
t = 'Skipped';
|
||||
} else if(triggered === true) {
|
||||
t = triggeredIndicator(true);
|
||||
}
|
||||
return `* ${name} - ${t} - ${result || '-'}`;
|
||||
});
|
||||
return results.join('\r\n');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user