Compare commits

..

33 Commits

Author SHA1 Message Date
FoxxMD
e58a0f8f21 Merge branch 'edge' 2022-03-14 12:39:05 -04:00
FoxxMD
c46fe6f128 feat(dispatch): Add cancel action in ui for delayed actions 2022-03-14 12:06:43 -04:00
FoxxMD
074c3c7340 fix(migrations): Escape prefix value before parsing to regex 2022-03-11 15:25:12 -05:00
FoxxMD
8cd8374bbe feat(filters): Add more options for filtering by flair
* Add boolean options to test for "any" or "no" flair on author or submission
* Cleanup flair testing logic
2022-03-11 15:08:37 -05:00
FoxxMD
aa0541f09b refactor(author filter): Improve caching for author filter
* Refactor main author filter logic into subredditresources to take advantage of cache provider
* Implement methods to retrieve and cache subreddit moderators and author information
2022-03-11 14:21:16 -05:00
FoxxMD
eee166467d feat(ui): Improve live stats utilization
* Refactor live stats to work for "All Subreddits" as well as individual subs
* Refactor live stats to take place of opStats and update almost all bot stats live now (only cache breakdown TODO)
* Refactor opstats to return status of bots/subreddits only for ui indicators in tabs
2022-03-11 12:41:59 -05:00
FoxxMD
95b0e529e2 fix(schema): Relax schema for source type due to issues with generation 2022-03-10 14:58:08 -05:00
FoxxMD
45be87a72a fix(item filter): Fix unknown value results
* For unknown value on activity use correct reason and add to property map
* Test for undefined property in property map (shouldn't happen)
2022-03-10 13:04:16 -05:00
FoxxMD
d632364c7d fix(item filter): Fix multiple and unknown criteria
* Log different statements based on whether value or key was undefined
* Fix for-loop break when testing multiple criteria
2022-03-10 12:49:35 -05:00
FoxxMD
9e660214eb feat(schema): Add annotations for dispatched/source 2022-03-10 12:48:27 -05:00
FoxxMD
14340b3a65 fix(ui): Include dayjs plugin for sameOrAfter comparison 2022-03-10 12:47:50 -05:00
FoxxMD
b07402628e fix(dispatch): Push activity from user through firehose so delayed activities get same behavior as from polling 2022-03-10 11:42:30 -05:00
FoxxMD
035283a596 refactor(item filter): Improve source comparison
* Move activity source normalization and verification into own function (thrown on invalid source string)
* Correct source-filter comparison by comparing source to filter rather than other way around to make sure inclusive filter is passed
2022-03-10 11:41:36 -05:00
FoxxMD
cc46f00a22 refactor(dispatch): Rename everything associated with rerun to dispatch
* Also rename item filter from 'dispatch' to 'dispatched' to match verb tense of other state properties
* Simplify identifier property name in config to just 'identifier' -- there's enough context for what it is already
2022-03-10 11:05:21 -05:00
FoxxMD
27263928cd Merge branch 'edge' into rerun 2022-03-10 10:14:58 -05:00
FoxxMD
0f122466ad feat(editor): Retrieving config schema from local URL instead of github
So that our schemas finally match whats in our code!
2022-03-10 10:12:31 -05:00
FoxxMD
32cdb29515 fix(ui): Only fetch reddit status if status element is present on page 2022-03-10 10:11:51 -05:00
FoxxMD
fe311ced32 feat(testing): Add suites for testing activity state 2022-03-09 17:15:40 -05:00
FoxxMD
e41bea7e6b fix(util): Always return false for filtered activity check if user is not a moderator 2022-03-09 17:15:21 -05:00
FoxxMD
9d169cebf3 Add test js/maps to gitignore 2022-03-09 17:14:53 -05:00
FoxxMD
ff3e704cdf fix(client): Simplify client logging to ui and fix instance name
* Correctly render system logs to html
* Simplify websocket logging so it matches how logs are received o browser from server
* Fix instance redirect name when no friendly is set for api config
2022-03-09 12:11:14 -05:00
FoxxMD
caaeb2eefb fix(ui): Set system logs as seen in special case 2022-03-09 12:09:05 -05:00
FoxxMD
8991797d35 feat(testing): WIP added initial testing framework and some util tests
Using mocha, chai, and nyc

* tests for parsing string for numeric value comparison
* tests for parsing string for durations and duration comparisons
* tests for parsing reddit entity (subreddit/user) from string
* tests for parsing submission/comment id from reddit permalink string
* tests for initial config parsing/merging

Still can't get nyc to get coverage for everything in src using "all" -- causes reporting to show 0 for everything??
2022-03-09 10:59:43 -05:00
FoxxMD
aa95c26b2a Merge branch 'edge' into rerun 2022-03-08 21:30:53 -05:00
FoxxMD
11cc90e2d5 feat: Add modnote oauth permission for auth helper 2022-03-08 21:30:37 -05:00
FoxxMD
d11e511f67 Merge branch 'edge' into rerun 2022-03-08 13:02:25 -05:00
FoxxMD
a3708ca279 refactor(ui): Improve live stats usage and add delayed item info
* Update activities and bot usage stats (just overview) with live data from websocket
* Add Delayed count with tooltip to show delayed items overview
2022-03-08 13:00:40 -05:00
FoxxMD
14d0417a25 refactor(dispatch): Rename rerun to dispatch action 2022-03-07 17:04:55 -05:00
FoxxMD
c4adf4f495 feat(ui): More identifier readability and succinctness improvements 2022-03-04 21:50:48 -05:00
FoxxMD
95d146a504 feat(ui): Make dispatch action context in actioned event more succinct
Move summary into tooltip and add relevant details to header
2022-03-04 21:39:41 -05:00
FoxxMD
ccc8a0dab5 Merge branch 'edge' into rerun
# Conflicts:
#	src/Web/assets/views/events.ejs
2022-03-04 21:37:39 -05:00
FoxxMD
1f3d0b50a7 feat: Implement re-run
* Implement rerun configuration that satisfies requirements from #72
  * rerun as action
  * optional, user-defined identifier
  * cancel rerun as action
  * cancel based on re-queued sources
  * on existing behavior
  * can specify initial goto
* filter item by source (where item was retrieved from for non-cached items)
* filter item by rerun state/identifier
* Add rerun label to event logging
* Add rerun data to actioned event data
2022-03-04 15:52:05 -05:00
FoxxMD
d8d409ae6b Some rerun basics 2022-03-03 16:34:39 -05:00
51 changed files with 6968 additions and 720 deletions

View File

@@ -6,3 +6,4 @@ src/logs
.github
/docs/
/node_modules/
coverage

2
.gitignore vendored
View File

@@ -381,6 +381,8 @@ dist
.pnp.*
**/src/**/*.js
**/tests/**/*.js
**/tests/**/*.map
!src/Web/assets/public/yaml/*
**/src/**/*.map
/**/*.sqlite

View File

@@ -2,10 +2,13 @@
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<sourceFolder url="file://$MODULE_DIR$/tests" isTestSource="true" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/src/logs" />
<excludeFolder url="file://$MODULE_DIR$/coverage" />
<excludeFolder url="file://$MODULE_DIR$/.nyc_output" />
</content>
<content url="file://$MODULE_DIR$/node_modules" />
<orderEntry type="inheritedJdk" />

4
.mocharc.json Normal file
View File

@@ -0,0 +1,4 @@
{
"require": ["./register.js", "source-map-support/register"],
"reporter": "dot"
}

24
.nycrc.json Normal file
View File

@@ -0,0 +1,24 @@
{
"extends": "@istanbuljs/nyc-config-typescript",
"exclude": [
"node_modules/",
"**/src/Schema/**",
"**/src/Web/assets/**",
"**/tests/**",
"register.js",
"**/src/**/*.d.ts"
],
"include": [
"**/src/**/*.ts",
"**/src/**/*.js",
"**/src/**/*.js.map"
],
"extension": [
".ts"
],
"reporter": [
"text-summary",
"html"
],
"report-dir": "./coverage"
}

4191
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no tests installed\" && exit 1",
"test": "nyc ./node_modules/.bin/_mocha 'tests/**/*.test.ts'",
"build": "tsc",
"start": "node src/index.js run",
"schema": "npm run -s schema-app & npm run -s schema-ruleset & npm run -s schema-rule & npm run -s schema-action & npm run -s schema-config",
@@ -89,10 +89,12 @@
"zlib": "^1.0.5"
},
"devDependencies": {
"@istanbuljs/nyc-config-typescript": "^1.0.2",
"@tsconfig/node14": "^1.0.0",
"@types/async": "^3.2.7",
"@types/cache-manager": "^3.4.2",
"@types/cache-manager-redis-store": "^2.0.0",
"@types/chai": "^4.3.0",
"@types/cookie-parser": "^1.4.2",
"@types/express": "^4.17.13",
"@types/express-session": "^1.17.4",
@@ -105,6 +107,7 @@
"@types/lodash": "^4.14.171",
"@types/lru-cache": "^5.1.1",
"@types/memory-cache": "^0.2.1",
"@types/mocha": "^9.1.0",
"@types/mustache": "^4.1.1",
"@types/node": "^15.6.1",
"@types/node-fetch": "^2.5.10",
@@ -116,8 +119,15 @@
"@types/string-similarity": "^4.0.0",
"@types/tcp-port-used": "^1.0.0",
"@types/triple-beam": "^1.3.2",
"chai": "^4.3.6",
"mocha": "^9.2.1",
"nyc": "^15.1.0",
"source-map-support": "^0.5.21",
"ts-essentials": "^9.1.2",
"ts-json-schema-generator": "^0.93.0",
"ts-mockito": "^2.6.1",
"ts-node": "^10.7.0",
"tsconfig-paths": "^3.13.0",
"typescript-json-schema": "~0.53"
},
"optionalDependencies": {

21
register.js Normal file
View File

@@ -0,0 +1,21 @@
/**
* Overrides the tsconfig used for the app.
* In the test environment we need some tweaks.
*/
const tsNode = require('ts-node');
const tsConfigPaths = require('tsconfig-paths');
const mainTSConfig = require('./tsconfig.json');
tsConfigPaths.register({
baseUrl: './tests',
paths: {
...mainTSConfig.compilerOptions.paths,
}
});
tsNode.register({
files: true,
transpileOnly: true,
project: './tsconfig.json'
});

View File

@@ -12,30 +12,37 @@ import {MessageAction, MessageActionJson} from "./MessageAction";
import {SubredditResources} from "../Subreddit/SubredditResources";
import {UserFlairAction, UserFlairActionJson} from './UserFlairAction';
import {ExtendedSnoowrap} from '../Utils/SnoowrapClients';
import EventEmitter from "events";
import {DispatchAction, DispatchActionJson} from "./DispatchAction";
import {CancelDispatchAction, CancelDispatchActionJson} from "./CancelDispatchAction";
export function actionFactory
(config: ActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap): Action {
(config: ActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap, emitter: EventEmitter): Action {
switch (config.kind) {
case 'comment':
return new CommentAction({...config as CommentActionJson, logger, subredditName, resources, client});
return new CommentAction({...config as CommentActionJson, logger, subredditName, resources, client, emitter});
case 'lock':
return new LockAction({...config, logger, subredditName, resources, client});
return new LockAction({...config, logger, subredditName, resources, client, emitter});
case 'remove':
return new RemoveAction({...config, logger, subredditName, resources, client});
return new RemoveAction({...config, logger, subredditName, resources, client, emitter});
case 'report':
return new ReportAction({...config as ReportActionJson, logger, subredditName, resources, client});
return new ReportAction({...config as ReportActionJson, logger, subredditName, resources, client, emitter});
case 'flair':
return new FlairAction({...config as FlairActionJson, logger, subredditName, resources, client});
return new FlairAction({...config as FlairActionJson, logger, subredditName, resources, client, emitter});
case 'userflair':
return new UserFlairAction({...config as UserFlairActionJson, logger, subredditName, resources, client});
return new UserFlairAction({...config as UserFlairActionJson, logger, subredditName, resources, client, emitter});
case 'approve':
return new ApproveAction({...config as ApproveActionConfig, logger, subredditName, resources, client});
return new ApproveAction({...config as ApproveActionConfig, logger, subredditName, resources, client, emitter});
case 'usernote':
return new UserNoteAction({...config as UserNoteActionJson, logger, subredditName, resources, client});
return new UserNoteAction({...config as UserNoteActionJson, logger, subredditName, resources, client, emitter});
case 'ban':
return new BanAction({...config as BanActionJson, logger, subredditName, resources, client});
return new BanAction({...config as BanActionJson, logger, subredditName, resources, client, emitter});
case 'message':
return new MessageAction({...config as MessageActionJson, logger, subredditName, resources, client});
return new MessageAction({...config as MessageActionJson, logger, subredditName, resources, client, emitter});
case 'dispatch':
return new DispatchAction({...config as DispatchActionJson, logger, subredditName, resources, client, emitter});
case 'cancelDispatch':
return new CancelDispatchAction({...config as CancelDispatchActionJson, logger, subredditName, resources, client, emitter})
default:
throw new Error('rule "kind" was not recognized.');
}

View File

@@ -2,13 +2,13 @@ import {ActionJson, ActionConfig, ActionOptions} from "./index";
import Action from "./index";
import Snoowrap from "snoowrap";
import {RuleResult} from "../Rule";
import {ActionProcessResult} from "../Common/interfaces";
import {ActionProcessResult, ActionTarget} from "../Common/interfaces";
import Submission from "snoowrap/dist/objects/Submission";
import Comment from "snoowrap/dist/objects/Comment";
export class ApproveAction extends Action {
targets: ApproveTarget[]
targets: ActionTarget[]
getKind() {
return 'Approve';
@@ -75,8 +75,6 @@ export class ApproveAction extends Action {
}
}
export type ApproveTarget = 'self' | 'parent';
export interface ApproveOptions extends ApproveActionConfig, ActionOptions {}
export interface ApproveActionConfig extends ActionConfig {
@@ -88,7 +86,7 @@ export interface ApproveActionConfig extends ActionConfig {
* * self => approve activity being checked (comment)
* * parent => approve parent (submission) of activity being checked (comment)
* */
targets?: ApproveTarget[]
targets?: ActionTarget[]
}
/**

View File

@@ -0,0 +1,135 @@
import {ActionJson, ActionConfig, ActionOptions} from "./index";
import Action from "./index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {RuleResult} from "../Rule";
import {activityIsRemoved} from "../Utils/SnoowrapUtils";
import {ActionProcessResult, ActionTarget, ActivityDispatchConfig, InclusiveActionTarget} from "../Common/interfaces";
import dayjs from "dayjs";
import {isSubmission, parseDurationValToDuration} from "../util";
export class CancelDispatchAction extends Action {
identifiers?: (string | null)[];
targets: InclusiveActionTarget[];
getKind() {
return 'Cancel Dispatch';
}
constructor(options: CancelDispatchOptions) {
super(options);
const {
identifier,
target
} = options;
if (identifier === undefined) {
this.identifiers = identifier;
} else {
this.identifiers = !Array.isArray(identifier) ? [identifier] : identifier;
}
this.targets = !Array.isArray(target) ? [target] : target;
}
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionProcessResult> {
const dryRun = runtimeDryrun || this.dryRun;
const realTargets = isSubmission(item) ? this.targets.filter(x => x !== 'parent') : this.targets;
if (this.targets.includes('parent') && isSubmission(item)) {
if (realTargets.length > 0) {
this.logger.warning(`Cannot use 'parent' as target because Activity is a Submission. Using other targets instead (${realTargets.join(',')})`);
} else {
return {
dryRun,
success: false,
result: `Cannot use 'parent' as target because Activity is a Submission and no other targets specified.`,
}
}
}
let cancelledActivities: string[] = [];
for (const target of realTargets) {
let matchId: string | undefined = item.name;
if (target === 'parent') {
matchId = (item as Comment).link_id;
} else if (target === 'any') {
matchId = undefined;
}
const delayedItemsToRemove = this.resources.delayedItems.filter(x => {
const matchedId = matchId === undefined || x.activity.name === matchId;
let matchedDispatchIdentifier;
if (this.identifiers === undefined) {
matchedDispatchIdentifier = true;
} else if (x.identifier === undefined) {
matchedDispatchIdentifier = this.identifiers.includes(null);
} else {
matchedDispatchIdentifier = this.identifiers.filter(x => x !== null).includes(x.identifier);
}
const matched = matchedId && matchedDispatchIdentifier;
if(matched && x.processing) {
this.logger.debug(`Cannot remove ${isSubmission(x.activity) ? 'Submission' : 'Comment'} ${x.activity.name} because it is currently processing`);
return false;
}
return matched;
});
let cancelCrit;
if (this.identifiers === undefined) {
cancelCrit = 'Any';
} else {
const idenfitierHints = [];
if (this.identifiers.includes(null)) {
idenfitierHints.push('No Identifier');
}
const concreteIdentifiers = this.identifiers.filter(x => x !== null);
if (concreteIdentifiers.length > 0) {
idenfitierHints.push(concreteIdentifiers.join(', '));
}
cancelCrit = idenfitierHints.join(' OR ');
}
let activityHint;
if (target === 'self') {
activityHint = 'This Activity';
} else if (target === 'parent') {
activityHint = `This Comment's parent Submission`;
} else {
activityHint = 'Any';
}
let cancelActivitiesHint;
if (delayedItemsToRemove.length === 0) {
cancelActivitiesHint = 'None Found';
} else {
const cancelActivitiesHintArr = delayedItemsToRemove.map(x => `${isSubmission(x.activity) ? 'Submission' : 'Comment'} ${x.activity.name}`);
cancelledActivities = cancelledActivities.concat(cancelActivitiesHintArr);
cancelActivitiesHint = cancelActivitiesHintArr.join(', ');
}
const cancelResult = `Identifiers: ${cancelCrit} | Target: ${activityHint} | Results: ${cancelActivitiesHint}`;
this.logger.verbose(cancelResult);
if (!dryRun) {
const activityIds = delayedItemsToRemove.map(x => x.id);
this.resources.delayedItems = this.resources.delayedItems.filter(x => !activityIds.includes(x.id));
}
}
return {
dryRun,
success: true,
result: cancelledActivities.length === 0 ? 'No Dispatch Actions cancelled' : `Cancelled Dispatch Actions: ${cancelledActivities.join(', ')}`,
}
}
}
export interface CancelDispatchOptions extends CancelDispatchActionConfig, ActionOptions {
}
export interface CancelDispatchActionConfig extends ActionConfig {
target: InclusiveActionTarget | InclusiveActionTarget[]
identifier?: string | string[] | null
}
/**
* Remove the Activity
* */
export interface CancelDispatchActionJson extends CancelDispatchActionConfig, ActionJson {
kind: 'cancelDispatch'
}

View File

@@ -0,0 +1,140 @@
import {ActionJson, ActionConfig, ActionOptions} from "./index";
import Action from "./index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {RuleResult} from "../Rule";
import {activityIsRemoved} from "../Utils/SnoowrapUtils";
import {ActionProcessResult, ActionTarget, ActivityDispatchConfig} from "../Common/interfaces";
import dayjs from "dayjs";
import {isSubmission, parseDurationValToDuration, randomId} from "../util";
export class DispatchAction extends Action {
dispatchData: ActivityDispatchConfig;
targets: ActionTarget[];
getKind() {
return 'Dispatch';
}
constructor(options: DispatchOptions) {
super(options);
const {
identifier,
cancelIfQueued = false,
goto,
delay,
target = ['self']
} = options;
this.dispatchData = {
identifier: identifier,
cancelIfQueued,
goto,
delay,
}
this.targets = !Array.isArray(target) ? [target] : target;
}
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionProcessResult> {
const dryRun = runtimeDryrun || this.dryRun;
const realTargets = isSubmission(item) ? ['self'] : this.targets;
if (this.targets.includes('parent') && isSubmission(item)) {
if (this.targets.includes('self')) {
this.logger.warning(`Cannot use 'parent' as target because Activity is a Submission. Reverted to 'self'`);
} else {
return {
dryRun,
success: false,
result: `Cannot use 'parent' as target because Activity is a Submission.`,
}
}
}
const {delay, ...restDispatchData} = this.dispatchData;
const dispatchPayload = {
...restDispatchData,
delay,
queuedAt: dayjs().unix(),
duration: parseDurationValToDuration(delay),
processing: false,
};
const dispatchActivitiesHints = [];
for (const target of realTargets) {
let act = item;
let actHint = `Comment's parent Submission (${(item as Comment).link_id})`;
if (target !== 'self') {
if (!dryRun) {
act = await this.resources.getActivity(this.client.getSubmission((item as Comment).link_id));
} else {
// don't need to spend api call to get submission if we won't actually do anything with it
// @ts-ignore
act = await this.resources.client.getSubmission((item as Comment).link_id);
}
} else {
actHint = `This Activity (${item.name})`;
}
const existing = this.resources.delayedItems.filter(x => {
const matchedActivityId = x.activity.name === act.name;
const matchDispatchIdentifier = dispatchPayload.identifier === undefined ? true : dispatchPayload.identifier === x.identifier;
return matchedActivityId && matchDispatchIdentifier;
});
if (existing.length > 0) {
let existingRes = `Dispatch activities (${existing.map((x, index) => `[${index + 1}] Queued At ${dayjs.unix(x.queuedAt).format('YYYY-MM-DD HH:mm:ssZ')} for ${x.duration.humanize()}`).join(' ')}}) already exist for ${actHint}`;
if (this.dispatchData.onExistingFound === 'skip') {
existingRes += ` and existing behavior is SKIP so nothing queued`;
continue;
} else if (this.dispatchData.onExistingFound === 'replace') {
existingRes += ` and existing behavior is REPLACE so replaced existing`;
const existingIds = existing.map(x => x.id);
this.resources.delayedItems = this.resources.delayedItems.filter(x => !existingIds.includes(x.id));
} else {
existingRes += ` but existing behavior is IGNORE so adding new dispatch activity anyway`;
}
dispatchActivitiesHints.push(existingRes);
} else {
dispatchActivitiesHints.push(actHint);
}
if (!dryRun) {
this.resources.delayedItems.push({
...dispatchPayload,
activity: act,
id: randomId(),
action: this.getActionUniqueName()
});
}
}
let dispatchBehaviors = [];
if (dispatchPayload.identifier !== undefined) {
dispatchBehaviors.push(`Identifier: ${dispatchPayload.identifier}`);
}
if (dispatchPayload.goto !== undefined) {
dispatchBehaviors.push(`Goto: ${dispatchPayload.goto}`);
}
let result = `Delay: ${dispatchPayload.duration.humanize()}${dispatchBehaviors.length > 0 ? ` | ${dispatchBehaviors.join(' | ')}` : ''} | Dispatch Results: ${dispatchActivitiesHints.join(' <<>> ')}`;
this.logger.verbose(result);
return {
dryRun,
success: true,
result,
}
}
}
export interface DispatchOptions extends DispatchActionConfig, ActionOptions {
}
export interface DispatchActionConfig extends ActionConfig, ActivityDispatchConfig {
target: ActionTarget | ActionTarget[]
}
/**
* Remove the Activity
* */
export interface DispatchActionJson extends DispatchActionConfig, ActionJson {
kind: 'dispatch'
}

View File

@@ -8,6 +8,8 @@ import {mergeArr} from "../util";
import LoggedError from "../Utils/LoggedError";
import {ExtendedSnoowrap} from '../Utils/SnoowrapClients';
import {ErrorWithCause} from "pony-cause";
import EventEmitter from "events";
import {runCheckOptions} from "../Subreddit/Manager";
export abstract class Action {
name?: string;
@@ -18,6 +20,7 @@ export abstract class Action {
itemIs: TypedActivityStates;
dryRun: boolean;
enabled: boolean;
managerEmitter: EventEmitter;
constructor(options: ActionOptions) {
const {
@@ -34,6 +37,7 @@ export abstract class Action {
exclude = [],
} = {},
itemIs = [],
emitter,
} = options;
this.name = name;
@@ -42,6 +46,7 @@ export abstract class Action {
this.resources = resources;
this.client = client;
this.logger = logger.child({labels: [`Action ${this.getActionUniqueName()}`]}, mergeArr);
this.managerEmitter = emitter;
this.authorIs = {
excludeCondition,
@@ -58,7 +63,8 @@ export abstract class Action {
return this.name === this.getKind() ? this.getKind() : `${this.getKind()} - ${this.name}`;
}
async handle(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionResult> {
async handle(item: Comment | Submission, ruleResults: RuleResult[], options: runCheckOptions): Promise<ActionResult> {
const {dryRun: runtimeDryrun} = options;
const dryRun = runtimeDryrun || this.dryRun;
let actRes: ActionResult = {
@@ -69,7 +75,7 @@ export abstract class Action {
success: false,
};
try {
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(item, this.itemIs, this.resources, this.logger);
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(item, this.itemIs, this.resources, this.logger, options.source);
if (!itemPass) {
this.logger.verbose(`Activity did not pass 'itemIs' test, Action not run`);
actRes.runReason = `Activity did not pass 'itemIs' test, Action not run`;
@@ -111,6 +117,7 @@ export interface ActionOptions extends ActionConfig {
subredditName: string;
resources: SubredditResources;
client: ExtendedSnoowrap;
emitter: EventEmitter
}
export interface ActionConfig extends ChecksActivityState {
@@ -157,7 +164,7 @@ export interface ActionJson extends ActionConfig {
/**
* The type of action that will be performed
*/
kind: 'comment' | 'lock' | 'remove' | 'report' | 'approve' | 'ban' | 'flair' | 'usernote' | 'message' | 'userflair'
kind: 'comment' | 'lock' | 'remove' | 'report' | 'approve' | 'ban' | 'flair' | 'usernote' | 'message' | 'userflair' | 'dispatch' | 'cancelDispatch'
}
export const isActionJson = (obj: object): obj is ActionJson => {

View File

@@ -47,19 +47,30 @@ export interface AuthorCriteria {
name?: string[],
/**
* A (user) flair css class (or list of) from the subreddit to match against
*
* * If `true` then passes if ANY css is assigned
* * If `false` then passes if NO css is assigned
* @examples ["red"]
* */
flairCssClass?: string | string[],
flairCssClass?: boolean | string | string[],
/**
* A (user) flair text value (or list of) from the subreddit to match against
*
* * If `true` then passes if ANY text is assigned
* * If `false` then passes if NO text is assigned
*
* @examples ["Approved"]
* */
flairText?: string | string[],
flairText?: boolean | string | string[],
/**
* A (user) flair template id (or list of) from the subreddit to match against
*
* * If `true` then passes if ANY template is assigned
* * If `false` then passed if NO template is assigned
*
* */
flairTemplate?: string | string[]
flairTemplate?: boolean | string | string[]
/**
* Is the author a moderator?
* */
@@ -137,8 +148,8 @@ export interface AuthorCriteria {
export class Author implements AuthorCriteria {
name?: string[];
flairCssClass?: string[];
flairText?: string[];
flairCssClass?: boolean | string[];
flairText?: boolean | string[];
isMod?: boolean;
userNotes?: UserNoteCriteria[];
age?: string;

View File

@@ -148,7 +148,7 @@ export abstract class Check implements ICheck {
this.actions.push(actionFactory({
...aj,
dryRun: this.dryRun || aj.dryRun
}, this.logger, subredditName, this.resources, this.client));
}, this.logger, subredditName, this.resources, this.client, this.emitter));
// @ts-ignore
a.logger = this.logger;
} else {
@@ -198,7 +198,7 @@ export abstract class Check implements ICheck {
async setCacheResult(item: Submission | Comment, result: UserResultCache): Promise<void> {
}
async handle(activity: (Submission | Comment), allRuleResults: RuleResult[], options: runCheckOptions = {}): Promise<CheckSummary> {
async handle(activity: (Submission | Comment), allRuleResults: RuleResult[], options: runCheckOptions): Promise<CheckSummary> {
let checkSum: CheckSummary = {
name: this.name,
@@ -226,7 +226,7 @@ export abstract class Check implements ICheck {
let checkRes: CheckResult;
let checkError: string | undefined;
try {
checkRes = await this.runRules(activity, allRuleResults);
checkRes = await this.runRules(activity, allRuleResults, options);
checkSum = {
...checkSum,
@@ -267,7 +267,7 @@ export abstract class Check implements ICheck {
try {
checkSum.postBehavior = this.postTrigger;
checkSum.actionResults = await this.runActions(activity, currentResults.filter(x => x.triggered), options.dryRun);
checkSum.actionResults = await this.runActions(activity, currentResults.filter(x => x.triggered), options);
// we only can about report and comment actions since those can produce items for newComm and modqueue
const recentCandidates = checkSum.actionResults.filter(x => ['report', 'comment'].includes(x.kind.toLocaleLowerCase())).map(x => x.touchedEntities === undefined ? [] : x.touchedEntities).flat();
for (const recent of recentCandidates) {
@@ -336,7 +336,7 @@ export abstract class Check implements ICheck {
}
}
async runRules(item: Submission | Comment, existingResults: RuleResult[] = []): Promise<CheckResult> {
async runRules(item: Submission | Comment, existingResults: RuleResult[] = [], options: runCheckOptions): Promise<CheckResult> {
try {
let allRuleResults: RuleResult[] = [];
let allResults: (RuleResult | RuleSetResult)[] = [];
@@ -357,7 +357,7 @@ export abstract class Check implements ICheck {
};
}
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(item, this.itemIs, this.resources, this.logger);
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(item, this.itemIs, this.resources, this.logger, options.source);
if (!itemPass) {
return {
triggered: false,
@@ -390,7 +390,7 @@ export abstract class Check implements ICheck {
for (const r of this.rules) {
//let results: RuleResult | RuleSetResult;
const combinedResults = [...existingResults, ...allRuleResults];
const [passed, results] = await r.run(item, combinedResults);
const [passed, results] = await r.run(item, combinedResults, options);
if (isRuleSetResult(results)) {
allRuleResults = allRuleResults.concat(results.results);
} else {
@@ -442,8 +442,9 @@ export abstract class Check implements ICheck {
}
}
async runActions(item: Submission | Comment, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionResult[]> {
const dr = runtimeDryrun || this.dryRun;
async runActions(item: Submission | Comment, ruleResults: RuleResult[], options: runCheckOptions): Promise<ActionResult[]> {
const {dryRun} = options;
const dr = dryRun || this.dryRun;
this.logger.debug(`${dr ? 'DRYRUN - ' : ''}Running Actions`);
const runActions: ActionResult[] = [];
for (const a of this.actions) {
@@ -459,7 +460,7 @@ export abstract class Check implements ICheck {
this.logger.info(`Action ${a.getActionUniqueName()} not run because it is not enabled.`);
continue;
}
const res = await a.handle(item, ruleResults, runtimeDryrun);
const res = await a.handle(item, ruleResults, options);
runActions.push(res);
}
this.logger.info(`${dr ? 'DRYRUN - ' : ''}Ran Actions: ${runActions.map(x => x.name).join(' | ')}`);
@@ -544,10 +545,9 @@ export interface CheckJson extends ICheck {
*
* Can be `Action` or the `name` of any **named** `Action` in your subreddit's configuration
*
* @minItems 1
* @examples [[{"kind": "comment", "content": "this is the content of the comment", "distinguish": true}, {"kind": "lock"}]]
* */
actions: Array<ActionTypeJson>
actions?: Array<ActionTypeJson>
/**
* If notifications are configured and this is `true` then an `eventActioned` event will be sent when this check is triggered.

View File

@@ -1,6 +1,6 @@
import {Cache} from 'cache-manager';
import {ActionedEvent, CheckSummary, RunResult} from "../../interfaces";
import {parseStringToRegex, redisScanIterator} from "../../../util";
import {escapeRegex, parseStringToRegex, redisScanIterator} from "../../../util";
export const up = async (context: any, next: any) => {
const client = context.client as Cache;
@@ -16,7 +16,7 @@ export const up = async (context: any, next: any) => {
subredditEventMap[nonPrefixedKey] = await client.get(nonPrefixedKey);
}
} else if(client.store.keys !== undefined) {
const eventsReg = parseStringToRegex(`/${prefix !== undefined ? prefix : ''}actionedEvents-.*/i`) as RegExp;
const eventsReg = parseStringToRegex(`/${prefix !== undefined ? escapeRegex(prefix) : ''}actionedEvents-.*/i`) as RegExp;
for (const key of await client.store.keys()) {
if(eventsReg.test(key)) {
const nonPrefixedKey = prefix !== undefined ? key.replace(prefix, '') : key;

View File

@@ -939,6 +939,35 @@ export interface ActivityState {
* */
reports?: CompareValue
age?: DurationComparor
/**
* Test whether the activity is present in dispatched/delayed activities
*
* NOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`
*
* * `true` => activity exists in delayed activities
* * `false` => activity DOES NOT exist in delayed activities
* * `string` => activity exists in delayed activities with given identifier
* * `string[]` => activity exists in delayed activities with any of the given identifiers
*
* */
dispatched?: boolean | string | string[]
// can use ActivitySource | ActivitySource[] here because of issues with generating json schema, see ActivitySource comments
/**
* Test where the current activity was sourced from.
*
* A source can be any of:
*
* * `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)
* * `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue
* * valid sources: unmoderated modqueue newComm newSub
* * `dispatch` => activity is from Dispatch Action
* * `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier
* * `user` => activity was from user input (web dashboard)
*
* */
source?: string | string[]
}
/**
@@ -958,9 +987,21 @@ export interface SubmissionState extends ActivityState {
* */
title?: string
link_flair_text?: string | string[]
link_flair_css_class?: string | string[]
flairTemplate?: string | string[]
/**
* * If `true` then passes if flair has ANY text
* * If `false` then passes if flair has NO text
* */
link_flair_text?: boolean | string | string[]
/**
* * If `true` then passes if flair has ANY css
* * If `false` then passes if flair has NO css
* */
link_flair_css_class?: boolean | string | string[]
/**
* * If `true` then passes if there is ANY flair template id
* * If `false` then passes if there is NO flair template id
* */
flairTemplate?: boolean | string | string[]
/**
* Is the submission a reddit-hosted image or video?
* */
@@ -2021,6 +2062,7 @@ export interface ActionedEvent {
subreddit: string,
triggered: boolean,
runResults: RunResult[]
dispatchSource?: DispatchAudit
}
export interface CheckResult {
@@ -2277,3 +2319,59 @@ export type ActivityType = 'submission' | 'comment';
export type ItemCritPropHelper = SafeDictionary<FilterCriteriaPropertyResult<(CommentState & SubmissionState)>, keyof (CommentState & SubmissionState)>;
export type RequiredItemCrit = Required<(CommentState & SubmissionState)>;
export type onExistingFoundBehavior = 'replace' | 'skip' | 'ignore';
export interface ActivityDispatchConfig {
identifier?: string
cancelIfQueued?: boolean | NonDispatchActivitySource | NonDispatchActivitySource[]
goto?: string
onExistingFound?: onExistingFoundBehavior
delay: DurationVal
}
export interface ActivityDispatch extends ActivityDispatchConfig {
id: string
queuedAt: number
activity: Submission | Comment
duration: Duration
processing: boolean
action: string
}
export interface DispatchAudit {
goto?: string
queuedAt: number
action: string,
delay: string,
id: string
identifier?: string
}
export type ActionTarget = 'self' | 'parent';
export type InclusiveActionTarget = ActionTarget | 'any';
export type DispatchSource = 'dispatch' | `dispatch:${string}`;
export type NonDispatchActivitySource = 'poll' | `poll:${PollOn}` | 'user';
// TODO
// https://github.com/YousefED/typescript-json-schema/issues/426
// https://github.com/YousefED/typescript-json-schema/issues/425
// @pattern ^(((poll|dispatch)(:\w+)?)|user)$
// @type string
/**
* Where an Activity was retrieved from
*
* Source can be any of:
*
* * `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)
* * `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue
* * `dispatch` => activity is from Dispatch Action
* * `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier
* * `user` => activity was from user input (web dashboard)
*
*
* */
export type ActivitySource = NonDispatchActivitySource | DispatchSource;

View File

@@ -15,11 +15,16 @@ import {BanActionJson} from "../Action/BanAction";
import {RegexRuleJSONConfig} from "../Rule/RegexRule";
import {MessageActionJson} from "../Action/MessageAction";
import {RepostRuleJSONConfig} from "../Rule/RepostRule";
import {DispatchActionJson} from "../Action/DispatchAction";
import {CancelDispatchActionJson} from "../Action/CancelDispatchAction";
import {SafeDictionary} from "ts-essentials";
import {FilterCriteriaPropertyResult} from "./interfaces";
import {AuthorCriteria} from "../Author/Author";
export type RuleJson = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | string;
export type RuleObjectJson = Exclude<RuleJson, string>
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | string;
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | string;
export type ActionObjectJson = Exclude<ActionJson, string>;
// borrowed from https://github.com/jabacchetta/set-random-interval/blob/master/src/index.ts
@@ -30,3 +35,6 @@ export type SetRandomInterval = (
) => { clear: () => void };
export type ConfigFormat = 'json' | 'yaml';
export type AuthorCritPropHelper = SafeDictionary<FilterCriteriaPropertyResult<AuthorCriteria>, keyof AuthorCriteria>;
export type RequiredAuthorCrit = Required<AuthorCriteria>;

View File

@@ -148,9 +148,9 @@ export class ConfigBuilder {
for(const r of realRuns) {
for (const c of r.checks) {
const {rules = []} = c;
const {rules = [], actions = []} = c;
namedRules = extractNamedRules(rules, namedRules);
namedActions = extractNamedActions(c.actions, namedActions);
namedActions = extractNamedActions(actions, namedActions);
}
}
@@ -164,9 +164,9 @@ export class ConfigBuilder {
const structuredChecks: CheckStructuredJson[] = [];
for (const c of r.checks) {
const {rules = [], authorIs = {}, itemIs = []} = c;
const {rules = [], actions = [], authorIs = {}, itemIs = []} = c;
const strongRules = insertNamedRules(rules, namedRules);
const strongActions = insertNamedActions(c.actions, namedActions);
const strongActions = insertNamedActions(actions, namedActions);
const [derivedAuthorIs, derivedItemIs] = mergeFilters(c, filterCriteriaDefaultsFromRun ?? (filterCriteriaDefaults ?? filterCriteriaDefaultsFromBot));

View File

@@ -8,6 +8,7 @@ import * as RuleSchema from '../Schema/Rule.json';
import Ajv from 'ajv';
import {RuleJson, RuleObjectJson} from "../Common/types";
import {SubredditResources} from "../Subreddit/SubredditResources";
import {runCheckOptions} from "../Subreddit/Manager";
export class RuleSet implements IRuleSet {
rules: Rule[] = [];
@@ -33,12 +34,12 @@ export class RuleSet implements IRuleSet {
}
}
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[boolean, RuleSetResult]> {
async run(item: Comment | Submission, existingResults: RuleResult[] = [], options: runCheckOptions): Promise<[boolean, RuleSetResult]> {
let results: RuleResult[] = [];
let runOne = false;
for (const r of this.rules) {
const combinedResults = [...existingResults, ...results];
const [passed, result] = await r.run(item, combinedResults);
const [passed, result] = await r.run(item, combinedResults, options);
//results = results.concat(determineNewResults(combinedResults, result));
results.push(result);
// skip rule if author check failed

View File

@@ -5,6 +5,7 @@ import {findResultByPremise, mergeArr} from "../util";
import {checkAuthorFilter, checkItemFilter, SubredditResources} from "../Subreddit/SubredditResources";
import {ChecksActivityState, FilterResult, TypedActivityState, TypedActivityStates} from "../Common/interfaces";
import Author, {AuthorCriteria, AuthorOptions} from "../Author/Author";
import {runCheckOptions} from "../Subreddit/Manager";
export interface RuleOptions {
name?: string;
@@ -52,7 +53,7 @@ export const isRuleSetResult = (obj: any): obj is RuleSetResult => {
}
export interface Triggerable {
run(item: Comment | Submission, existingResults: RuleResult[]): Promise<[(boolean | null), RuleResult?]>;
run(item: Comment | Submission, existingResults: RuleResult[], options: runCheckOptions): Promise<[(boolean | null), RuleResult?]>;
}
export abstract class Rule implements IRule, Triggerable {
@@ -92,14 +93,14 @@ export abstract class Rule implements IRule, Triggerable {
this.logger = logger.child({labels: [`Rule ${this.getRuleUniqueName()}`]}, mergeArr);
}
async run(item: Comment | Submission, existingResults: RuleResult[] = []): Promise<[(boolean | null), RuleResult]> {
async run(item: Comment | Submission, existingResults: RuleResult[] = [], options: runCheckOptions): Promise<[(boolean | null), RuleResult]> {
try {
const existingResult = findResultByPremise(this.getPremise(), existingResults);
if (existingResult) {
this.logger.debug(`Returning existing result of ${existingResult.triggered ? '✔️' : '❌'}`);
return Promise.resolve([existingResult.triggered, {...existingResult, name: this.name, fromCache: true}]);
}
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(item, this.itemIs, this.resources, this.logger);
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(item, this.itemIs, this.resources, this.logger, options.source);
if (!itemPass) {
this.logger.verbose(`(Skipped) Item did not pass 'itemIs' test`);
return Promise.resolve([null, this.getResult(null, {result: `Item did not pass 'itemIs' test`})]);

View File

@@ -98,7 +98,7 @@ export class Run {
}
}
async handle(activity: (Submission | Comment), initAllRuleResults: RuleResult[], existingRunResults: RunResult[] = [], options?: runCheckOptions): Promise<[RunResult, string]> {
async handle(activity: (Submission | Comment), initAllRuleResults: RuleResult[], existingRunResults: RunResult[] = [], options: runCheckOptions): Promise<[RunResult, string]> {
let allRuleResults = initAllRuleResults;
let continueRunIteration = true;
@@ -111,7 +111,8 @@ export class Run {
const {
maxGotoDepth = 1,
gotoContext: optGotoContext = '',
} = options || {};
source,
} = options;
if(!this.enabled) {
runResult.error = 'Not enabled';
@@ -141,7 +142,7 @@ export class Run {
try {
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(activity, this.itemIs, this.resources, this.logger)
const [itemPass, itemFilterType, itemFilterResults] = await checkItemFilter(activity, this.itemIs, this.resources, this.logger, source)
if (!itemPass) {
this.logger.verbose(`${FAIL} => Item did not pass 'itemIs' test`);
return [{

View File

@@ -58,10 +58,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair css class (or list of) from the subreddit to match against",
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
"examples": [
"red"
]
@@ -75,10 +78,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair template id (or list of) from the subreddit to match against"
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
},
"flairText": {
"anyOf": [
@@ -89,10 +95,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair text value (or list of) from the subreddit to match against",
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
"examples": [
"Approved"
]
@@ -209,6 +218,23 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
"type": "string"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -235,6 +261,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},
@@ -271,6 +311,23 @@
"deleted": {
"type": "boolean"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -286,9 +343,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if there is ANY flair template id\n* If `false` then passes if there is NO flair template id"
},
"isRedditMediaDomain": {
"description": "Is the submission a reddit-hosted image or video?",
@@ -306,9 +367,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
},
"link_flair_text": {
"anyOf": [
@@ -319,9 +384,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
},
"locked": {
"type": "boolean"
@@ -346,6 +415,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},
@@ -456,7 +539,9 @@
"enum": [
"approve",
"ban",
"cancelDispatch",
"comment",
"dispatch",
"flair",
"lock",
"message",

View File

@@ -525,10 +525,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair css class (or list of) from the subreddit to match against",
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
"examples": [
"red"
]
@@ -542,10 +545,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair template id (or list of) from the subreddit to match against"
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
},
"flairText": {
"anyOf": [
@@ -556,10 +562,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair text value (or list of) from the subreddit to match against",
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
"examples": [
"Approved"
]
@@ -1023,6 +1032,120 @@
],
"type": "string"
},
"CancelDispatchActionJson": {
"description": "Remove the Activity",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
"description": "If present then these Author criteria are checked before running the Action. If criteria fails then the Action is not run.",
"examples": [
{
"include": [
{
"flairText": [
"Contributor",
"Veteran"
]
},
{
"isMod": true
}
]
}
]
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
"examples": [
false,
true
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"identifier": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"null",
"string"
]
}
]
},
"itemIs": {
"description": "A list of criteria to test the state of the `Activity` against before running the Action.\n\nIf any set of criteria passes the Action will be run.",
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
}
]
},
"type": "array"
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"cancelDispatch"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"examples": [
"myDescriptiveAction"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"target": {
"anyOf": [
{
"items": {
"enum": [
"any",
"parent",
"self"
],
"type": "string"
},
"type": "array"
},
{
"enum": [
"any",
"parent",
"self"
],
"type": "string"
}
]
}
},
"required": [
"kind",
"target"
],
"type": "object"
},
"CommentActionJson": {
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
"properties": {
@@ -1146,24 +1269,18 @@
],
"items": {
"anyOf": [
{
"$ref": "#/definitions/FlairActionJson"
},
{
"$ref": "#/definitions/UserFlairActionJson"
},
{
"$ref": "#/definitions/CommentActionJson"
},
{
"$ref": "#/definitions/ReportActionJson"
},
{
"$ref": "#/definitions/LockActionJson"
},
{
"$ref": "#/definitions/RemoveActionJson"
},
{
"$ref": "#/definitions/ReportActionJson"
},
{
"$ref": "#/definitions/UserNoteActionJson"
},
@@ -1176,12 +1293,23 @@
{
"$ref": "#/definitions/MessageActionJson"
},
{
"$ref": "#/definitions/UserFlairActionJson"
},
{
"$ref": "#/definitions/DispatchActionJson"
},
{
"$ref": "#/definitions/CancelDispatchActionJson"
},
{
"$ref": "#/definitions/FlairActionJson"
},
{
"type": "string"
}
]
},
"minItems": 1,
"type": "array"
},
"authorIs": {
@@ -1315,7 +1443,6 @@
}
},
"required": [
"actions",
"kind",
"name"
],
@@ -1346,6 +1473,23 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
"type": "string"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -1372,6 +1516,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},
@@ -1410,6 +1568,157 @@
],
"type": "object"
},
"DispatchActionJson": {
"description": "Remove the Activity",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
"description": "If present then these Author criteria are checked before running the Action. If criteria fails then the Action is not run.",
"examples": [
{
"include": [
{
"flairText": [
"Contributor",
"Veteran"
]
},
{
"isMod": true
}
]
}
]
},
"cancelIfQueued": {
"anyOf": [
{
"items": {
"enum": [
"poll",
"poll:modqueue",
"poll:newComm",
"poll:newSub",
"poll:unmoderated",
"user"
],
"type": "string"
},
"type": "array"
},
{
"enum": [
false,
"poll",
"poll:modqueue",
"poll:newComm",
"poll:newSub",
"poll:unmoderated",
true,
"user"
]
}
]
},
"delay": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"type": "string"
}
]
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
"examples": [
false,
true
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"goto": {
"type": "string"
},
"identifier": {
"type": "string"
},
"itemIs": {
"description": "A list of criteria to test the state of the `Activity` against before running the Action.\n\nIf any set of criteria passes the Action will be run.",
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
}
]
},
"type": "array"
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"dispatch"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"examples": [
"myDescriptiveAction"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"onExistingFound": {
"enum": [
"ignore",
"replace",
"skip"
],
"type": "string"
},
"target": {
"anyOf": [
{
"items": {
"enum": [
"parent",
"self"
],
"type": "string"
},
"type": "array"
},
{
"enum": [
"parent",
"self"
],
"type": "string"
}
]
}
},
"required": [
"delay",
"kind",
"target"
],
"type": "object"
},
"DurationObject": {
"additionalProperties": false,
"description": "A [Day.js duration object](https://day.js.org/docs/en/durations/creating)",
@@ -3387,24 +3696,18 @@
],
"items": {
"anyOf": [
{
"$ref": "#/definitions/FlairActionJson"
},
{
"$ref": "#/definitions/UserFlairActionJson"
},
{
"$ref": "#/definitions/CommentActionJson"
},
{
"$ref": "#/definitions/ReportActionJson"
},
{
"$ref": "#/definitions/LockActionJson"
},
{
"$ref": "#/definitions/RemoveActionJson"
},
{
"$ref": "#/definitions/ReportActionJson"
},
{
"$ref": "#/definitions/UserNoteActionJson"
},
@@ -3417,12 +3720,23 @@
{
"$ref": "#/definitions/MessageActionJson"
},
{
"$ref": "#/definitions/UserFlairActionJson"
},
{
"$ref": "#/definitions/DispatchActionJson"
},
{
"$ref": "#/definitions/CancelDispatchActionJson"
},
{
"$ref": "#/definitions/FlairActionJson"
},
{
"type": "string"
}
]
},
"minItems": 1,
"type": "array"
},
"authorIs": {
@@ -3556,7 +3870,6 @@
}
},
"required": [
"actions",
"kind",
"name"
],
@@ -3582,6 +3895,23 @@
"deleted": {
"type": "boolean"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -3597,9 +3927,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if there is ANY flair template id\n* If `false` then passes if there is NO flair template id"
},
"isRedditMediaDomain": {
"description": "Is the submission a reddit-hosted image or video?",
@@ -3617,9 +3951,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
},
"link_flair_text": {
"anyOf": [
@@ -3630,9 +3968,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
},
"locked": {
"type": "boolean"
@@ -3657,6 +3999,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},

View File

@@ -58,10 +58,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair css class (or list of) from the subreddit to match against",
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
"examples": [
"red"
]
@@ -75,10 +78,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair template id (or list of) from the subreddit to match against"
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
},
"flairText": {
"anyOf": [
@@ -89,10 +95,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair text value (or list of) from the subreddit to match against",
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
"examples": [
"Approved"
]
@@ -504,6 +513,23 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
"type": "string"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -530,6 +556,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},
@@ -1141,6 +1181,23 @@
"deleted": {
"type": "boolean"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -1156,9 +1213,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if there is ANY flair template id\n* If `false` then passes if there is NO flair template id"
},
"isRedditMediaDomain": {
"description": "Is the submission a reddit-hosted image or video?",
@@ -1176,9 +1237,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
},
"link_flair_text": {
"anyOf": [
@@ -1189,9 +1254,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
},
"locked": {
"type": "boolean"
@@ -1216,6 +1285,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},

View File

@@ -466,10 +466,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair css class (or list of) from the subreddit to match against",
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
"examples": [
"red"
]
@@ -483,10 +486,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair template id (or list of) from the subreddit to match against"
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
},
"flairText": {
"anyOf": [
@@ -497,10 +503,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair text value (or list of) from the subreddit to match against",
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
"examples": [
"Approved"
]
@@ -687,6 +696,23 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
"type": "string"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -713,6 +739,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},
@@ -1952,6 +1992,23 @@
"deleted": {
"type": "boolean"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -1967,9 +2024,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if there is ANY flair template id\n* If `false` then passes if there is NO flair template id"
},
"isRedditMediaDomain": {
"description": "Is the submission a reddit-hosted image or video?",
@@ -1987,9 +2048,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
},
"link_flair_text": {
"anyOf": [
@@ -2000,9 +2065,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
},
"locked": {
"type": "boolean"
@@ -2027,6 +2096,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},

View File

@@ -440,10 +440,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair css class (or list of) from the subreddit to match against",
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
"examples": [
"red"
]
@@ -457,10 +460,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair template id (or list of) from the subreddit to match against"
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
},
"flairText": {
"anyOf": [
@@ -471,10 +477,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
],
"description": "A (user) flair text value (or list of) from the subreddit to match against",
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
"examples": [
"Approved"
]
@@ -661,6 +670,23 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(days|weeks|months|years|hours|minutes|seconds|milliseconds)\\s*$",
"type": "string"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -687,6 +713,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},
@@ -1926,6 +1966,23 @@
"deleted": {
"type": "boolean"
},
"dispatched": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "Test whether the activity is present in dispatched/delayed activities\n\nNOTE: This is DOES NOT mean that THIS activity is from dispatch -- just that it exists there. To test whether THIS activity is from dispatch use `source`\n\n* `true` => activity exists in delayed activities\n* `false` => activity DOES NOT exist in delayed activities\n* `string` => activity exists in delayed activities with given identifier\n* `string[]` => activity exists in delayed activities with any of the given identifiers"
},
"distinguished": {
"type": "boolean"
},
@@ -1941,9 +1998,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if there is ANY flair template id\n* If `false` then passes if there is NO flair template id"
},
"isRedditMediaDomain": {
"description": "Is the submission a reddit-hosted image or video?",
@@ -1961,9 +2022,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
},
"link_flair_text": {
"anyOf": [
@@ -1974,9 +2039,13 @@
"type": "array"
},
{
"type": "string"
"type": [
"string",
"boolean"
]
}
]
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
},
"locked": {
"type": "boolean"
@@ -2001,6 +2070,20 @@
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"source": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Test where the current activity was sourced from.\n\nA source can be any of:\n\n* `poll` => activity was retrieved from polling a queue (unmoderated, modqueue, etc...)\n* `poll:[pollSource]` => activity was retrieved from specific polling source IE `poll:unmoderated` activity comes from unmoderated queue\n * valid sources: unmoderated modqueue newComm newSub\n* `dispatch` => activity is from Dispatch Action\n* `dispatch:[identifier]` => activity is from Dispatch Action with specific identifier\n* `user` => activity was from user input (web dashboard)"
},
"spam": {
"type": "boolean"
},

View File

@@ -3,13 +3,14 @@ import {Logger} from "winston";
import {SubmissionCheck} from "../Check/SubmissionCheck";
import {CommentCheck} from "../Check/CommentCheck";
import {
asComment,
asSubmission,
cacheStats,
createHistoricalStatsDisplay,
createRetryHandler,
determineNewResults,
findLastIndex,
formatNumber, isSubmission, likelyJson5,
formatNumber, getActivityAuthorName, isComment, isSubmission, likelyJson5,
mergeArr, normalizeName,
parseFromJsonOrYamlToObject,
parseRedditEntity,
@@ -23,11 +24,32 @@ import {RuleResult} from "../Rule";
import {ConfigBuilder, buildPollingOptions} from "../ConfigBuilder";
import {
ActionedEvent,
ActionResult, CheckResult, CheckSummary,
ActionResult,
ActivityDispatch,
ActivitySource,
CheckResult,
CheckSummary,
DEFAULT_POLLING_INTERVAL,
DEFAULT_POLLING_LIMIT, FilterCriteriaDefaults, Invokee, LogInfo,
ManagerOptions, ManagerStateChangeOption, ManagerStats, NotificationEventPayload, PAUSED,
PollingOptionsStrong, PollOn, PostBehavior, PostBehaviorTypes, RUNNING, RunResult, RunState, STOPPED, SYSTEM, USER
DEFAULT_POLLING_LIMIT,
FilterCriteriaDefaults,
Invokee,
LogInfo,
ManagerOptions,
ManagerStateChangeOption,
ManagerStats,
NotificationEventPayload,
PAUSED,
PollingOptionsStrong,
PollOn,
PostBehavior,
PostBehaviorTypes, DispatchAudit,
DispatchSource,
RUNNING,
RunResult,
RunState,
STOPPED,
SYSTEM,
USER
} from "../Common/interfaces";
import Submission from "snoowrap/dist/objects/Submission";
import {activityIsRemoved, itemContentPeek} from "../Utils/SnoowrapUtils";
@@ -67,11 +89,14 @@ export interface runCheckOptions {
force?: boolean,
gotoContext?: string
maxGotoDepth?: number
source: ActivitySource
initialGoto?: string
dispatchSource?: DispatchAudit
}
export interface CheckTask {
activity: (Submission | Comment),
options?: runCheckOptions
options: runCheckOptions
}
export interface RuntimeManagerOptions extends ManagerOptions {
@@ -201,6 +226,26 @@ export class Manager extends EventEmitter {
return data;
}
getDelayedSummary = (): any[] => {
if(this.resources === undefined) {
return [];
}
return this.resources.delayedItems.map((x) => {
return {
id: x.id,
activityId: x.activity.name,
permalink: x.activity.permalink,
submissionId: asComment(x.activity) ? x.activity.link_id : undefined,
author: getActivityAuthorName(x.activity.author),
queuedAt: x.queuedAt,
durationMilli: x.duration.asMilliseconds(),
duration: x.duration.humanize(),
source: `${x.action}${x.identifier !== undefined ? ` (${x.identifier})` : ''}`,
subreddit: this.subreddit.display_name_prefixed
}
});
}
getCurrentLabels = () => {
return this.currentLabels;
}
@@ -368,10 +413,49 @@ export class Manager extends EventEmitter {
this.queuedItemsMeta.push({id: task.activity.id, shouldRefresh: false, state: 'queued'});
this.queue.push(task);
}
if(!task.options.source.includes('dispatch')) {
// check for delayed items to cancel
const existingDelayedToCancel = this.resources.delayedItems.filter(x => {
if (x.activity.name === task.activity.name) {
const {cancelIfQueued = false} = x;
if(cancelIfQueued === false) {
return false;
} else if (cancelIfQueued === true) {
return true;
} else {
const cancelFrom = !Array.isArray(cancelIfQueued) ? [cancelIfQueued] : cancelIfQueued;
return cancelFrom.map(x => x.toLowerCase()).includes(task.options.source.toLowerCase());
}
}
});
if(existingDelayedToCancel.length > 0) {
this.logger.debug(`Cancelling existing delayed activities due to activity being queued from non-dispatch sources: ${existingDelayedToCancel.map((x, index) => `[${index + 1}] Queued At ${dayjs.unix(x.queuedAt).format('YYYY-MM-DD HH:mm:ssZ')} for ${x.duration.humanize()}`).join(' ')}`);
const toCancelIds = existingDelayedToCancel.map(x => x.id);
this.resources.delayedItems.filter(x => !toCancelIds.includes(x.id));
}
}
}
, 1);
}
protected async startDelayQueue() {
while(this.queueState.state === RUNNING) {
let index = 0;
for(const ar of this.resources.delayedItems) {
if(!ar.processing && dayjs.unix(ar.queuedAt).add(ar.duration.asMilliseconds(), 'milliseconds').isSameOrBefore(dayjs())) {
this.logger.info(`Delayed Activity ${ar.activity.name} is being queued.`);
const dispatchStr: DispatchSource = ar.identifier === undefined ? 'dispatch' : `dispatch:${ar.identifier}`;
await this.firehose.push({activity: ar.activity, options: {refresh: true, source: dispatchStr, initialGoto: ar.goto, dispatchSource: {id: ar.id, queuedAt: ar.queuedAt, delay: ar.duration.humanize(), action: ar.action, goto: ar.goto, identifier: ar.identifier}}});
this.resources.delayedItems.splice(index, 1, {...ar, processing: true});
}
index++;
}
// sleep for 5 seconds
await sleep(5000);
}
}
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.`);
@@ -387,10 +471,14 @@ export class Manager extends EventEmitter {
try {
const itemMeta = this.queuedItemsMeta[queuedItemIndex];
this.queuedItemsMeta.splice(queuedItemIndex, 1, {...itemMeta, state: 'processing'});
await this.handleActivity(task.activity, {...task.options, refresh: itemMeta.shouldRefresh});
await this.handleActivity(task.activity, {refresh: itemMeta.shouldRefresh, ...task.options});
} finally {
// always remove item meta regardless of success or failure since we are done with it meow
this.queuedItemsMeta.splice(queuedItemIndex, 1);
if(task.options.dispatchSource?.id !== undefined) {
const delayIndex = this.resources.delayedItems.findIndex(x => x.id === task.options.dispatchSource?.id);
this.resources.delayedItems.splice(delayIndex, 1);
}
}
}
, maxWorkers);
@@ -659,13 +747,13 @@ export class Manager extends EventEmitter {
}
}
async handleActivity(activity: (Submission | Comment), options?: runCheckOptions): Promise<void> {
async handleActivity(activity: (Submission | Comment), options: runCheckOptions): Promise<void> {
const checkType = isSubmission(activity) ? 'Submission' : 'Comment';
let item = activity;
const itemId = await item.id;
if(await this.resources.hasRecentSelf(item)) {
const {force = false} = options || {};
const {force = false} = options;
let recentMsg = `Found in Activities recently (last ${this.resources.selfTTL} seconds) modified/created by this bot`;
if(force) {
this.logger.debug(`${recentMsg} but will run anyway because "force" option was true.`);
@@ -675,15 +763,24 @@ export class Manager extends EventEmitter {
}
}
const {
delayUntil,
refresh = false,
initialGoto = '',
dispatchSource,
} = options;
let allRuleResults: RuleResult[] = [];
const runResults: RunResult[] = [];
const itemIdentifier = `${checkType === 'Submission' ? 'SUB' : 'COM'} ${itemId}`;
this.currentLabels = [itemIdentifier];
const itemIdentifiers = [];
itemIdentifiers.push(`${checkType === 'Submission' ? 'SUB' : 'COM'} ${itemId}`);
this.currentLabels = itemIdentifiers;
let ePeek = '';
try {
const [peek, { content: peekContent }] = await itemContentPeek(item);
ePeek = peekContent;
this.logger.info(`<EVENT> ${peek}`);
const dispatchStr = dispatchSource !== undefined ? ` (Dispatched by ${dispatchSource.action}${dispatchSource.identifier !== undefined ? ` | ${dispatchSource.identifier}` : ''}) ${peek}` : peek;
this.logger.info(`<EVENT> ${dispatchStr}`);
} catch (err: any) {
this.logger.error(`Error occurred while generating item peek for ${checkType} Activity ${itemId}`, err);
}
@@ -699,16 +796,12 @@ export class Manager extends EventEmitter {
author: item.author.name,
subreddit: item.subreddit_name_prefixed
},
dispatchSource: dispatchSource,
timestamp: Date.now(),
runResults: []
}
const startingApiLimit = this.client.ratelimitRemaining;
const {
delayUntil,
refresh = false,
} = options || {};
let wasRefreshed = false;
try {
@@ -727,7 +820,7 @@ export class Manager extends EventEmitter {
// 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)');
this.logger.verbose(`Refreshed data ${dispatchSource !== undefined ? 'b/c activity is from dispatch' : 'b/c activity was delayed'}`);
// @ts-ignore
item = await activity.refresh();
}
@@ -748,7 +841,7 @@ export class Manager extends EventEmitter {
let continueRunIteration = true;
let runIndex = 0;
let gotoContext: string = '';
let gotoContext: string = initialGoto;
while(continueRunIteration && (runIndex < this.runs.length || gotoContext !== '')) {
let currRun: Run;
if(gotoContext !== '') {
@@ -962,7 +1055,7 @@ export class Manager extends EventEmitter {
continue;
}
const onItem = async (item: Comment | Submission) => {
const onItem = (source: PollOn) => async (item: Comment | Submission) => {
if (item.subreddit.display_name !== subName || this.eventsState.state !== RUNNING) {
return;
}
@@ -975,7 +1068,7 @@ export class Manager extends EventEmitter {
checkType = 'Comment';
}
if (checkType !== undefined) {
this.firehose.push({activity: item, options: {delayUntil}})
this.firehose.push({activity: item, options: {delayUntil, source: `poll:${source}`}})
}
};
@@ -991,7 +1084,7 @@ export class Manager extends EventEmitter {
stream.once('listing', this.noChecksWarning(source));
this.logger.debug(`${removedOwn ? 'Stopped own polling and replace with ' : 'Set '}listener on shared polling ${source}`);
}
this.sharedStreamCallbacks.set(source, onItem);
this.sharedStreamCallbacks.set(source, onItem(source));
} else {
let ownPollingMsgParts: string[] = [];
let removedShared = false;
@@ -1014,7 +1107,7 @@ export class Manager extends EventEmitter {
this.logger.debug(`Polling ${source.toUpperCase()} => ${ownPollingMsgParts.join('and')} dedicated stream`);
stream.on('item', onItem);
stream.on('item', onItem(source));
// @ts-ignore
stream.on('error', async (err: any) => {
@@ -1062,6 +1155,7 @@ export class Manager extends EventEmitter {
state: RUNNING,
causedBy
}
this.startDelayQueue();
if(!suppressNotification) {
this.notificationManager.handle('runStateChanged', 'Queue Started', reason, causedBy);
}

View File

@@ -1,12 +1,11 @@
import Snoowrap, {RedditUser} from "snoowrap";
import Snoowrap from "snoowrap";
import objectHash from 'object-hash';
import {
activityIsDeleted, activityIsFiltered,
activityIsRemoved,
AuthorActivitiesOptions,
AuthorTypedActivitiesOptions, BOT_LINK,
getAuthorActivities,
testAuthorCriteria
getAuthorActivities
} from "../Utils/SnoowrapUtils";
import winston, {Logger} from "winston";
import as from 'async';
@@ -22,19 +21,19 @@ import {
createCacheManager,
createHistoricalStatsDisplay, escapeRegex, FAIL,
fetchExternalUrl, filterCriteriaSummary,
formatNumber,
formatNumber, generateItemFilterHelpers,
getActivityAuthorName,
getActivitySubredditName,
getActivitySubredditName, isComment, isCommentState,
isStrongSubredditState, isSubmission, isUser,
mergeArr,
parseDurationComparison,
parseExternalUrl,
parseGenericValueComparison,
parseGenericValueComparison, parseGenericValueOrPercentComparison,
parseRedditEntity, parseStringToRegex,
parseWikiContext, PASS, redisScanIterator, removeUndefinedKeys,
shouldCacheSubredditStateCriteriaResult,
shouldCacheSubredditStateCriteriaResult, strToActivitySource,
subredditStateIsNameOnly, testMaybeStringRegex,
toStrongSubredditState, userNoteCriteriaSummary
toStrongSubredditState, truncateStringToLength, userNoteCriteriaSummary
} from "../util";
import LoggedError from "../Utils/LoggedError";
import {
@@ -60,7 +59,12 @@ import {
ThirdPartyCredentialsJsonConfig,
FilterCriteriaResult,
FilterResult,
TypedActivityState, RequiredItemCrit, ItemCritPropHelper,
TypedActivityState,
RequiredItemCrit,
ItemCritPropHelper,
ActivityDispatch,
FilterCriteriaPropertyResult,
ActivitySource,
} from "../Common/interfaces";
import UserNotes from "./UserNotes";
import Mustache from "mustache";
@@ -68,7 +72,7 @@ import he from "he";
import {AuthorCriteria, AuthorOptions} from "../Author/Author";
import {SPoll} from "./Streams";
import {Cache} from 'cache-manager';
import {Submission, Comment, Subreddit} from "snoowrap/dist/objects";
import {Submission, Comment, Subreddit, RedditUser} from "snoowrap/dist/objects";
import {cacheTTLDefaults, createHistoricalDefaults, historicalDefaults} from "../Common/defaults";
import {check} from "tcp-port-used";
import {ExtendedSnoowrap} from "../Utils/SnoowrapClients";
@@ -78,6 +82,8 @@ import globrex from 'globrex';
import {runMigrations} from "../Common/Migrations/CacheMigrationUtils";
import {isStatusError, SimpleError} from "../Utils/Errors";
import {ErrorWithCause} from "pony-cause";
import {UserNoteCriteria} from "../Rule";
import {AuthorCritPropHelper, RequiredAuthorCrit} from "../Common/types";
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
@@ -100,6 +106,7 @@ interface SubredditResourceOptions extends Footer {
prefix?: string;
actionedEventsMax: number;
thirdPartyCredentials: ThirdPartyCredentialsJsonConfig
delayedItems?: ActivityDispatch[]
}
export interface SubredditResourceSetOptions extends CacheConfig, Footer {
@@ -129,6 +136,7 @@ export class SubredditResources {
prefix?: string
actionedEventsMax: number;
thirdPartyCredentials: ThirdPartyCredentialsJsonConfig;
delayedItems: ActivityDispatch[] = [];
stats: {
cache: ResourceStats
@@ -156,8 +164,10 @@ export class SubredditResources {
cacheSettingsHash,
client,
thirdPartyCredentials,
delayedItems = [],
} = options || {};
this.delayedItems = delayedItems;
this.cacheSettingsHash = cacheSettingsHash;
this.cache = cache;
this.prefix = prefix;
@@ -603,6 +613,33 @@ export class SubredditResources {
}
}
async getSubredditModerators(subredditVal: Subreddit | string) {
const subName = typeof subredditVal === 'string' ? subredditVal : subredditVal.display_name;
const hash = `sub-${subName}-moderators`;
if (this.subredditTTL !== false) {
const cachedSubredditMods = await this.cache.get(hash);
if (cachedSubredditMods !== undefined && cachedSubredditMods !== null) {
this.logger.debug(`Cache Hit: Subreddit Moderators ${subName}`);
return (cachedSubredditMods as string[]).map(x => new RedditUser({name: x}, this.client, false));
}
}
let sub: Subreddit;
if (typeof subredditVal !== 'string') {
sub = subredditVal;
} else {
sub = this.client.getSubreddit(subredditVal);
}
const mods = await sub.getModerators();
if (this.subredditTTL !== false) {
// @ts-ignore
await this.cache.set(hash, mods.map(x => x.name), {ttl: this.subredditTTL});
}
return mods;
}
async hasSubreddit(name: string) {
if (this.subredditTTL !== false) {
const hash = `sub-${name}`;
@@ -618,6 +655,44 @@ export class SubredditResources {
return false;
}
// @ts-ignore
async getAuthor(val: RedditUser | string) {
const authorName = typeof val === 'string' ? val : val.name;
const hash = `author-${authorName}`;
if (this.authorTTL !== false) {
const cachedAuthorData = await this.cache.get(hash);
if (cachedAuthorData !== undefined && cachedAuthorData !== null) {
this.logger.debug(`Cache Hit: Author ${authorName}`);
const {subreddit, ...rest} = cachedAuthorData as any;
const snoowrapConformedData = {...rest};
if(subreddit !== null) {
snoowrapConformedData.subreddit = {
display_name: subreddit
};
} else {
snoowrapConformedData.subreddit = null;
}
return new RedditUser(snoowrapConformedData, this.client, true);
}
}
let user: RedditUser;
if (typeof val !== 'string') {
user = val;
} else {
user = this.client.getUser(val);
}
// @ts-ignore
user = await user.fetch();
if (this.authorTTL !== false) {
// @ts-ignore
await this.cache.set(hash, user, {ttl: this.authorTTL});
}
return user;
}
async getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
const userName = getActivityAuthorName(user);
if (this.authorTTL !== false) {
@@ -861,16 +936,16 @@ export class SubredditResources {
return cachedAuthorTest;
} else {
this.stats.cache.authorCrit.miss++;
cachedAuthorTest = await testAuthorCriteria(item, authorOpts, include, this.userNotes);
cachedAuthorTest = await this.isAuthor(item, authorOpts, include);
await this.cache.set(hash, cachedAuthorTest, {ttl: this.filterCriteriaTTL});
return cachedAuthorTest;
}
}
return await testAuthorCriteria(item, authorOpts, include, this.userNotes);
return await this.isAuthor(item, authorOpts, include);
}
async testItemCriteria(i: (Comment | Submission), activityState: TypedActivityState, logger: Logger): Promise<FilterCriteriaResult<TypedActivityState>> {
async testItemCriteria(i: (Comment | Submission), activityState: TypedActivityState, logger: Logger, source?: ActivitySource): Promise<FilterCriteriaResult<TypedActivityState>> {
if(Object.keys(activityState).length === 0) {
return {
behavior: 'include',
@@ -881,21 +956,60 @@ export class SubredditResources {
}
if (this.filterCriteriaTTL !== false) {
let item = i;
let state = activityState;
const {dispatched, source: stateSource, ...rest} = activityState;
let state = rest;
// if using cache and dispatched is present we want to test for it separately from the rest of the state
// because it can change independently from the rest of the activity criteria (its only related to CM!) so storing in cache would make everything potentially stale
// -- additionally we keep that data in-memory (for now??) so its always accessible and doesn't need to be stored in cache
let runtimeRes: FilterCriteriaResult<(SubmissionState & CommentState)> | undefined;
if(dispatched !== undefined || stateSource !== undefined) {
runtimeRes = await this.isItem(item, {dispatched, source: stateSource}, logger, source);
if(!runtimeRes.passed) {
// if dispatched does not pass can return early and avoid testing the rest of the item
const [propResultsMap, definedStateCriteria] = generateItemFilterHelpers(rest);
if(dispatched !== undefined) {
propResultsMap.dispatched = runtimeRes.propertyResults.find(x => x.property === 'dispatched');
}
if(stateSource !== undefined) {
propResultsMap.source = runtimeRes.propertyResults.find(x => x.property === 'source');
}
return {
behavior: 'include',
criteria: activityState,
propertyResults: Object.values(propResultsMap),
passed: false
}
}
}
try {
// only cache non-runtime state and results
const hash = `itemCrit-${item.name}-${objectHash.sha1(state)}`;
await this.stats.cache.itemCrit.identifierRequestCount.set(hash, (await this.stats.cache.itemCrit.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
this.stats.cache.itemCrit.requestTimestamps.push(Date.now());
this.stats.cache.itemCrit.requests++;
const cachedItem = await this.cache.get(hash);
if (cachedItem !== undefined && cachedItem !== null) {
let itemResult = await this.cache.get(hash) as FilterCriteriaResult<TypedActivityState> | undefined | null;
if (itemResult !== undefined && itemResult !== null) {
this.logger.debug(`Cache Hit: Item Check on ${item.name} (Hash ${hash})`);
//return cachedItem as boolean;
} else {
itemResult = await this.isItem(item, state, logger);
}
const itemResult = await this.isItem(item, state, logger);
this.stats.cache.itemCrit.miss++;
await this.cache.set(hash, itemResult, {ttl: this.filterCriteriaTTL});
// add in runtime results, if present
if(runtimeRes !== undefined) {
if(dispatched !== undefined) {
itemResult.propertyResults.push(runtimeRes.propertyResults.find(x => x.property === 'dispatched') as FilterCriteriaPropertyResult<TypedActivityState>);
}
if(stateSource !== undefined) {
itemResult.propertyResults.push(runtimeRes.propertyResults.find(x => x.property === 'source') as FilterCriteriaPropertyResult<TypedActivityState>);
}
}
return itemResult;
} catch (err: any) {
if (err.logged !== true) {
@@ -905,7 +1019,7 @@ export class SubredditResources {
}
}
return await this.isItem(i, activityState, logger);
return await this.isItem(i, activityState, logger, source);
}
async isSubreddit (subreddit: Subreddit, stateCriteriaRaw: SubredditState | StrongSubredditState, logger: Logger) {
@@ -971,9 +1085,11 @@ export class SubredditResources {
})() as boolean;
}
async isItem (item: Submission | Comment, stateCriteria: TypedActivityState, logger: Logger): Promise<FilterCriteriaResult<(SubmissionState & CommentState)>> {
async isItem (item: Submission | Comment, stateCriteria: TypedActivityState, logger: Logger, source?: ActivitySource): Promise<FilterCriteriaResult<(SubmissionState & CommentState)>> {
const definedStateCriteria = (removeUndefinedKeys(stateCriteria) as RequiredItemCrit);
//const definedStateCriteria = (removeUndefinedKeys(stateCriteria) as RequiredItemCrit);
const [propResultsMap, definedStateCriteria] = generateItemFilterHelpers(stateCriteria);
const log = logger.child({leaf: 'Item Check'}, mergeArr);
@@ -986,25 +1102,21 @@ export class SubredditResources {
}
}
const propResultsMap = Object.entries(definedStateCriteria).reduce((acc: ItemCritPropHelper, [k, v]) => {
const key = (k as keyof (SubmissionState & CommentState));
acc[key] = {
property: key,
behavior: 'include',
};
return acc;
}, {});
// const propResultsMap = Object.entries(definedStateCriteria).reduce((acc: ItemCritPropHelper, [k, v]) => {
// const key = (k as keyof (SubmissionState & CommentState));
// acc[key] = {
// property: key,
// behavior: 'include',
// };
// return acc;
// }, {});
const keys = Object.keys(propResultsMap) as (keyof (SubmissionState & CommentState))[]
let shouldContinue;
try {
for(const k of keys) {
const itemOptVal = definedStateCriteria[k];
shouldContinue = undefined;
switch(k) {
case 'submissionState':
if(isSubmission(item)) {
@@ -1014,25 +1126,62 @@ export class SubredditResources {
propResultsMap.submissionState!.reason = subMsg;
break;
}
// get submission
// @ts-ignore
const subProxy = await this.client.getSubmission(await item.link_id);
// @ts-ignore
const sub = await this.getActivity(subProxy);
const subStates = itemOptVal as RequiredItemCrit['submissionState'];
// @ts-ignore
const subResults = [];
for(const subState of subStates) {
subResults.push(await this.testItemCriteria(sub, subState as SubmissionState, logger))
// // get submission
// // @ts-ignore
// const subProxy = await this.client.getSubmission(await item.link_id);
// // @ts-ignore
// const sub = await this.getActivity(subProxy);
//
// const subStates = itemOptVal as RequiredItemCrit['submissionState'];
// // @ts-ignore
// const subResults = [];
// for(const subState of subStates) {
// subResults.push(await this.testItemCriteria(sub, subState as SubmissionState, logger))
// }
// propResultsMap.submissionState!.passed = subResults.length === 0 || subResults.some(x => x.passed);
// propResultsMap.submissionState!.found = {
// join: 'OR',
// criteriaResults: subResults,
// passed: propResultsMap.submissionState!.passed
// };
break;
case 'dispatched':
const matchingDelayedActivities = this.delayedItems.filter(x => x.activity.name === item.name);
let found: string | boolean = matchingDelayedActivities.length > 0;
let reason: string | undefined;
let identifiers: string[] | undefined;
if(found && typeof itemOptVal !== 'boolean') {
identifiers = Array.isArray(itemOptVal) ? (itemOptVal as string[]) : [itemOptVal];
for(const i of identifiers) {
const matchingDelayedIdentifier = matchingDelayedActivities.find(x => x.identifier === i);
if(matchingDelayedIdentifier !== undefined) {
found = matchingDelayedIdentifier.identifier as string;
break;
}
}
if(found === true) {
reason = 'Found delayed activities but none matched dispatch identifier';
}
}
propResultsMap.submissionState!.passed = subResults.length === 0 || subResults.some(x => x.passed);
propResultsMap.submissionState!.found = {
join: 'OR',
criteriaResults: subResults,
passed: propResultsMap.submissionState!.passed
};
propResultsMap.dispatched!.passed = found === itemOptVal || typeof found === 'string';
propResultsMap.dispatched!.found = found;
propResultsMap.dispatched!.reason = reason;
break;
case 'source':
if(source === undefined) {
propResultsMap.source!.passed = false;
propResultsMap.source!.found = 'Not From Source';
propResultsMap.source!.reason = 'Activity was not retrieved from a source (may be from cache)';
break;
} else {
propResultsMap.source!.found = source;
const requestedSourcesVal: string[] = !Array.isArray(itemOptVal) ? [itemOptVal] as string[] : itemOptVal as string[];
const requestedSources = requestedSourcesVal.map(x => strToActivitySource(x).toLowerCase());
propResultsMap.source!.passed = requestedSources.some(x => source.toLowerCase().includes(x))
break;
}
case 'score':
const scoreCompare = parseGenericValueComparison(itemOptVal as string);
propResultsMap.score!.passed = comparisonTextOp(item.score, scoreCompare.operator, scoreCompare.value);
@@ -1179,45 +1328,52 @@ export class SubredditResources {
propResultsMap[k]!.found = propertyValue;
const expectedValues = typeof itemOptVal === 'string' ? [itemOptVal] : (itemOptVal as string[]);
const VALUEPass = () => {
for (const c of expectedValues) {
if (c === propertyValue) {
return true;
}
if (typeof itemOptVal === 'boolean') {
if (itemOptVal === true) {
propResultsMap[k]!.passed = propertyValue !== undefined && propertyValue !== null && propertyValue !== '';
} else {
propResultsMap[k]!.passed = propertyValue === undefined || propertyValue === null || propertyValue === '';
}
return false;
};
propResultsMap[k]!.passed = VALUEPass();
} else if (propertyValue === undefined || propertyValue === null || propertyValue === '') {
// if crit is not a boolean but property is "empty" then it'll never pass anyway
propResultsMap[k]!.passed = false;
} else {
const expectedValues = typeof itemOptVal === 'string' ? [itemOptVal] : (itemOptVal as string[]);
propResultsMap[k]!.passed = expectedValues.some(x => x.trim().toLowerCase() === propertyValue?.trim().toLowerCase());
}
break;
} else {
propResultsMap[k]!.passed = true;
propResultsMap[k]!.reason = `Cannot test for ${k} on Comment`;
log.warn(`Cannot test for ${k} on Comment`);
break;
}
default:
// @ts-ignore
const val = item[k];
if (val !== undefined && propResultsMap[k] !== undefined) {
propResultsMap[k]!.found = val;
propResultsMap[k]!.passed = val === itemOptVal;
} else {
// this shouldn't happen
if(propResultsMap[k] === undefined) {
log.warn(`State criteria property ${k} was not found in property map?? This shouldn't happen`);
} else if(val === undefined) {
let defaultWarn = `Tried to test for Activity property '${k}' but it did not exist. Check the spelling of the property.`;
if(!item.can_mod_post) {
defaultWarn =`Tried to test for Activity property '${k}' but it did not exist. This Activity is not in a subreddit the bot can mod so it may be that this property is only available to mods of that subreddit. Or the property may be misspelled.`;
}
propResultsMap.depth!.passed = true;
propResultsMap.depth!.reason = defaultWarn;
log.debug(defaultWarn);
propResultsMap[k]!.found = 'undefined';
propResultsMap[k]!.reason = defaultWarn;
} else {
propResultsMap[k]!.found = val;
propResultsMap[k]!.passed = val === itemOptVal;
}
break;
}
if(!propResultsMap[k]!.passed && shouldContinue === undefined) {
shouldContinue = false;
}
if(!shouldContinue) {
if(propResultsMap[k] !== undefined && propResultsMap[k]!.passed === false) {
break;
}
}
@@ -1237,6 +1393,381 @@ export class SubredditResources {
};
}
async isAuthor(item: (Comment | Submission), authorOpts: AuthorCriteria, include = true): Promise<FilterCriteriaResult<AuthorCriteria>> {
const definedAuthorOpts = (removeUndefinedKeys(authorOpts) as RequiredAuthorCrit);
let fetchedUser: RedditUser | undefined;
// @ts-ignore
const user = async (): Promise<RedditUser> => {
if(fetchedUser === undefined) {
fetchedUser = await this.getAuthor(item.author);
}
// @ts-ignore
return fetchedUser;
}
const propResultsMap = Object.entries(definedAuthorOpts).reduce((acc: AuthorCritPropHelper, [k, v]) => {
const key = (k as keyof AuthorCriteria);
let ex;
if (Array.isArray(v)) {
ex = v.map(x => {
if (asUserNoteCriteria(x)) {
return userNoteCriteriaSummary(x);
}
return x;
});
} else {
ex = [v];
}
acc[key] = {
property: key,
behavior: include ? 'include' : 'exclude',
};
return acc;
}, {});
const {shadowBanned} = authorOpts;
if (shadowBanned !== undefined) {
try {
// @ts-ignore
await item.author.fetch();
// user is not shadowbanned
// if criteria specifies they SHOULD be shadowbanned then return false now
if (shadowBanned) {
propResultsMap.shadowBanned!.found = false;
propResultsMap.shadowBanned!.passed = false;
}
} catch (err: any) {
if (isStatusError(err) && err.statusCode === 404) {
// user is shadowbanned
// if criteria specifies they should not be shadowbanned then return false now
if (!shadowBanned) {
propResultsMap.shadowBanned!.found = true;
propResultsMap.shadowBanned!.passed = false;
}
} else {
throw err;
}
}
}
if (propResultsMap.shadowBanned === undefined || propResultsMap.shadowBanned.passed === undefined) {
try {
const authorName = getActivityAuthorName(item.author);
const keys = Object.keys(propResultsMap) as (keyof AuthorCriteria)[]
let shouldContinue = true;
for (const k of keys) {
if (k === 'shadowBanned') {
// we have already taken care of this with shadowban check above
continue;
}
const authorOptVal = definedAuthorOpts[k];
//if (authorOpts[k] !== undefined) {
switch (k) {
case 'name':
const nameVal = authorOptVal as RequiredAuthorCrit['name'];
const authPass = () => {
for (const n of nameVal) {
if (n.toLowerCase() === authorName.toLowerCase()) {
return true;
}
}
return false;
}
const authResult = authPass();
propResultsMap.name!.found = authorName;
propResultsMap.name!.passed = !((include && !authResult) || (!include && authResult));
if (!propResultsMap.name!.passed) {
shouldContinue = false;
}
break;
case 'flairCssClass':
const css = await item.author_flair_css_class;
propResultsMap.flairCssClass!.found = css;
let cssResult:boolean;
if (typeof authorOptVal === 'boolean') {
if (authorOptVal === true) {
cssResult = css !== undefined && css !== null && css !== '';
} else {
cssResult = css === undefined || css === null || css === '';
}
} else if (css === undefined || css === null || css === '') {
// if crit is not a boolean but property is "empty" then it'll never pass anyway
cssResult = false;
} else {
const opts = Array.isArray(authorOptVal) ? authorOptVal as string[] : [authorOptVal] as string[];
cssResult = opts.some(x => x.trim().toLowerCase() === css.trim().toLowerCase())
}
propResultsMap.flairCssClass!.passed = !((include && !cssResult) || (!include && cssResult));
if (!propResultsMap.flairCssClass!.passed) {
shouldContinue = false;
}
break;
case 'flairText':
const text = await item.author_flair_text;
propResultsMap.flairText!.found = text;
let textResult: boolean;
if (typeof authorOptVal === 'boolean') {
if (authorOptVal === true) {
textResult = text !== undefined && text !== null && text !== '';
} else {
textResult = text === undefined || text === null || text === '';
}
} else if (text === undefined || text === null) {
// if crit is not a boolean but property is "empty" then it'll never pass anyway
textResult = false;
} else {
const opts = Array.isArray(authorOptVal) ? authorOptVal as string[] : [authorOptVal] as string[];
textResult = opts.some(x => x.trim().toLowerCase() === text.trim().toLowerCase())
}
propResultsMap.flairText!.passed = !((include && !textResult) || (!include && textResult));
if (!propResultsMap.flairText!.passed) {
shouldContinue = false;
}
break;
case 'flairTemplate':
const templateId = await item.author_flair_template_id;
propResultsMap.flairTemplate!.found = templateId;
let templateResult: boolean;
if (typeof authorOptVal === 'boolean') {
if (authorOptVal === true) {
templateResult = templateId !== undefined && templateId !== null && templateId !== '';
} else {
templateResult = templateId === undefined || templateId === null || templateId === '';
}
} else if (templateId === undefined || templateId === null || templateId === '') {
// if crit is not a boolean but property is "empty" then it'll never pass anyway
templateResult = false;
} else {
const opts = Array.isArray(authorOptVal) ? authorOptVal as string[] : [authorOptVal] as string[];
templateResult = opts.some(x => x.trim() === templateId);
}
propResultsMap.flairTemplate!.passed = !((include && !templateResult) || (!include && templateResult));
if (!propResultsMap.flairTemplate!.passed) {
shouldContinue = false;
}
break;
case 'isMod':
const mods: RedditUser[] = await this.getSubredditModerators(item.subreddit);
const isModerator = mods.some(x => x.name === authorName) || authorName.toLowerCase() === 'automoderator';
const modMatch = authorOpts.isMod === isModerator;
propResultsMap.isMod!.found = isModerator;
propResultsMap.isMod!.passed = !((include && !modMatch) || (!include && modMatch));
if (!propResultsMap.isMod!.passed) {
shouldContinue = false;
}
break;
case 'age':
// @ts-ignore
const authorAge = dayjs.unix((await user()).created);
const ageTest = compareDurationValue(parseDurationComparison(await authorOpts.age as string), authorAge);
propResultsMap.age!.found = authorAge.fromNow(true);
propResultsMap.age!.passed = !((include && !ageTest) || (!include && ageTest));
if (!propResultsMap.age!.passed) {
shouldContinue = false;
}
break;
case 'linkKarma':
// @ts-ignore
const tk = (await user()).total_karma as number;
const lkCompare = parseGenericValueOrPercentComparison(await authorOpts.linkKarma as string);
let lkMatch;
if (lkCompare.isPercent) {
lkMatch = comparisonTextOp(item.author.link_karma / tk, lkCompare.operator, lkCompare.value / 100);
} else {
lkMatch = comparisonTextOp(item.author.link_karma, lkCompare.operator, lkCompare.value);
}
propResultsMap.linkKarma!.found = tk;
propResultsMap.linkKarma!.passed = !((include && !lkMatch) || (!include && lkMatch));
if (!propResultsMap.linkKarma!.passed) {
shouldContinue = false;
}
break;
case 'commentKarma':
// @ts-ignore
const ck = (await user()).comment_karma as number;
const ckCompare = parseGenericValueOrPercentComparison(await authorOpts.commentKarma as string);
let ckMatch;
if (ckCompare.isPercent) {
ckMatch = comparisonTextOp(item.author.comment_karma / ck, ckCompare.operator, ckCompare.value / 100);
} else {
ckMatch = comparisonTextOp(item.author.comment_karma, ckCompare.operator, ckCompare.value);
}
propResultsMap.commentKarma!.found = ck;
propResultsMap.commentKarma!.passed = !((include && !ckMatch) || (!include && ckMatch));
if (!propResultsMap.commentKarma!.passed) {
shouldContinue = false;
}
break;
case 'totalKarma':
// @ts-ignore
const totalKarma = (await user()).total_karma as number;
const tkCompare = parseGenericValueComparison(await authorOpts.totalKarma as string);
if (tkCompare.isPercent) {
throw new SimpleError(`'totalKarma' value on AuthorCriteria cannot be a percentage`);
}
const tkMatch = comparisonTextOp(totalKarma, tkCompare.operator, tkCompare.value);
propResultsMap.totalKarma!.found = totalKarma;
propResultsMap.totalKarma!.passed = !((include && !tkMatch) || (!include && tkMatch));
if (!propResultsMap.totalKarma!.passed) {
shouldContinue = false;
}
break;
case 'verified':
// @ts-ignore
const verified = (await user()).has_verified_mail;
const vMatch = verified === authorOpts.verified as boolean;
propResultsMap.verified!.found = verified;
propResultsMap.verified!.passed = !((include && !vMatch) || (!include && vMatch));
if (!propResultsMap.verified!.passed) {
shouldContinue = false;
}
break;
case 'description':
// @ts-ignore
const desc = (await user()).subreddit?.display_name.public_description;
const dVals = authorOpts[k] as string[];
let passed = false;
let passReg;
for (const val of dVals) {
let reg = parseStringToRegex(val, 'i');
if (reg === undefined) {
reg = parseStringToRegex(`/.*${escapeRegex(val.trim())}.*/`, 'i');
if (reg === undefined) {
throw new SimpleError(`Could not convert 'description' value to a valid regex: ${authorOpts[k] as string}`);
}
}
if (reg.test(desc)) {
passed = true;
passReg = reg.toString();
break;
}
}
propResultsMap.description!.found = typeof desc === 'string' ? truncateStringToLength(50)(desc) : desc;
propResultsMap.description!.passed = !((include && !passed) || (!include && passed));
if (!propResultsMap.description!.passed) {
shouldContinue = false;
} else {
propResultsMap.description!.reason = `Matched with: ${passReg as string}`;
}
break;
case 'userNotes':
const notes = await this.userNotes.getUserNotes(item.author);
let foundNoteResult: string[] = [];
const notePass = () => {
for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) {
const {count = '>= 1', search = 'current', type} = noteCriteria;
const {
value,
operator,
isPercent,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const order = extra.includes('asc') ? 'ascending' : 'descending';
switch (search) {
case 'current':
if (notes.length > 0) {
const currentNoteType = notes[notes.length - 1].noteType;
foundNoteResult.push(`Current => ${currentNoteType}`);
if (currentNoteType === type) {
return true;
}
} else {
foundNoteResult.push('No notes present');
}
break;
case 'consecutive':
let orderedNotes = notes;
if (order === 'descending') {
orderedNotes = [...notes];
orderedNotes.reverse();
}
let currCount = 0;
for (const note of orderedNotes) {
if (note.noteType === type) {
currCount++;
} else {
currCount = 0;
}
if (isPercent) {
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
}
foundNoteResult.push(`Found ${currCount} ${type} consecutively`);
if (comparisonTextOp(currCount, operator, value)) {
return true;
}
}
break;
case 'total':
const filteredNotes = notes.filter(x => x.noteType === type);
if (isPercent) {
// avoid divide by zero
const percent = notes.length === 0 ? 0 : filteredNotes.length / notes.length;
foundNoteResult.push(`${formatNumber(percent)}% are ${type}`);
if (comparisonTextOp(percent, operator, value / 100)) {
return true;
}
} else {
foundNoteResult.push(`${filteredNotes.length} are ${type}`);
if (comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
return true;
}
}
break;
}
}
return false;
}
const noteResult = notePass();
propResultsMap.userNotes!.found = foundNoteResult.join(' | ');
propResultsMap.userNotes!.passed = !((include && !noteResult) || (!include && noteResult));
if (!propResultsMap.userNotes!.passed) {
shouldContinue = false;
}
break;
}
//}
if (!shouldContinue) {
break;
}
}
} catch (err: any) {
if (isStatusError(err) && err.statusCode === 404) {
throw new SimpleError('Reddit returned a 404 while trying to retrieve User profile. It is likely this user is shadowbanned.');
} else {
throw err;
}
}
}
// gather values and determine overall passed
const propResults = Object.values(propResultsMap);
const passed = propResults.filter(x => typeof x.passed === 'boolean').every(x => x.passed === true);
return {
behavior: include ? 'include' : 'exclude',
criteria: authorOpts,
propertyResults: propResults,
passed,
};
}
async getCommentCheckCacheResult(item: Comment, checkConfig: object): Promise<UserResultCache | undefined> {
const userName = getActivityAuthorName(item.author);
const hash = `commentUserResult-${userName}-${item.link_id}-${objectHash.sha1(checkConfig)}`;
@@ -1436,7 +1967,7 @@ export class BotResourcesManager {
let resource: SubredditResources;
const res = this.get(subName);
if(res === undefined || res.cacheSettingsHash !== hash) {
resource = new SubredditResources(subName, opts);
resource = new SubredditResources(subName, {...opts, delayedItems: res?.delayedItems});
await resource.initHistoricalStats();
resource.setHistoricalSaveInterval();
this.resources.set(subName, resource);
@@ -1562,7 +2093,7 @@ export const checkAuthorFilter = async (item: (Submission | Comment), filter: Au
return [true, undefined, {criteriaResults: allCritResults, join: 'OR', passed: true}];
}
export const checkItemFilter = async (item: (Submission | Comment), filter: TypedActivityStates, resources: SubredditResources, parentLogger: Logger): Promise<[boolean, ('inclusive' | 'exclusive' | undefined), FilterResult<TypedActivityState>]> => {
export const checkItemFilter = async (item: (Submission | Comment), filter: TypedActivityStates, resources: SubredditResources, parentLogger: Logger, source?: ActivitySource): Promise<[boolean, ('inclusive' | 'exclusive' | undefined), FilterResult<TypedActivityState>]> => {
const logger = parentLogger.child({labels: ['Item Filter']}, mergeArr);
const allCritResults: FilterCriteriaResult<TypedActivityState>[] = [];
@@ -1570,7 +2101,51 @@ export const checkItemFilter = async (item: (Submission | Comment), filter: Type
if(filter.length > 0) {
let index = 1
for(const state of filter) {
const critResult = await resources.testItemCriteria(item, state, parentLogger);
let critResult: FilterCriteriaResult<TypedActivityState>;
// need to determine if criteria is for comment or submission state
// and if its comment state WITH submission state then break apart testing into individual activity testing
if(isCommentState(state) && isComment(item) && state.submissionState !== undefined) {
const {submissionState, ...restCommentState} = state;
// test submission state first since it's more likely(??) we have crit results or cache data for this submission than for the comment
// get submission
// @ts-ignore
const subProxy = await resources.client.getSubmission(await item.link_id);
// @ts-ignore
const sub = await resources.getActivity(subProxy);
const [subPass, _, subFilterResults] = await checkItemFilter(sub, submissionState, resources, parentLogger);
const subPropertyResult: FilterCriteriaPropertyResult<CommentState> = {
property: 'submissionState',
behavior: 'include',
passed: subPass,
found: {
join: 'OR',
criteriaResults: subFilterResults.criteriaResults,
passed: subPass,
}
};
if(!subPass) {
// generate dummy results for the rest of the comment state since we don't need to test it
const [propResultsMap, definedStateCriteria] = generateItemFilterHelpers(restCommentState);
propResultsMap.submissionState = subPropertyResult;
critResult = {
behavior: 'include',
criteria: state,
propertyResults: Object.values(propResultsMap),
passed: false
}
} else {
critResult = await resources.testItemCriteria(item, restCommentState, parentLogger, source);
critResult.criteria = state;
critResult.propertyResults.unshift(subPropertyResult);
}
} else {
critResult = await resources.testItemCriteria(item, state, parentLogger, source);
}
//critResult = await resources.testItemCriteria(item, state, parentLogger);
allCritResults.push(critResult);
const [summary, details] = filterCriteriaSummary(critResult);
if (critResult.passed) {

View File

@@ -20,7 +20,7 @@ import {
isActivityWindowCriteria, isSubmission, isUserNoteCriteria,
normalizeName,
parseDuration,
parseDurationComparison,
parseDurationComparison, parseDurationValToDuration,
parseGenericValueComparison,
parseGenericValueOrPercentComparison,
parseRuleResultsToMarkdownSummary, parseStringToRegex,
@@ -126,21 +126,7 @@ export async function getActivities(listingFunc: (limit: number) => Promise<List
if (durVal !== undefined) {
const endTime = dayjs();
if (typeof durVal === 'object') {
duration = dayjs.duration(durVal);
if (!dayjs.isDuration(duration)) {
throw new Error('window value given was not a well-formed Duration object');
}
} else {
try {
duration = parseDuration(durVal);
} catch (e) {
if (e instanceof InvalidRegexError) {
throw new Error(`window value of '${durVal}' could not be parsed as a valid ISO8601 duration or DayJS duration shorthand (see Schema)`);
}
throw e;
}
}
const duration = parseDurationValToDuration(durVal);
satisfiedEndtime = endTime.subtract(duration.asMilliseconds(), 'milliseconds');
}
@@ -356,355 +342,6 @@ export const renderContent = async (template: string, data: (Submission | Commen
return he.decode(rendered);
}
type AuthorCritPropHelper = SafeDictionary<FilterCriteriaPropertyResult<AuthorCriteria>, keyof AuthorCriteria>;
type RequiredAuthorCrit = Required<AuthorCriteria>;
export const testAuthorCriteria = async (item: (Comment | Submission), authorOpts: AuthorCriteria, include = true, userNotes: UserNotes): Promise<FilterCriteriaResult<AuthorCriteria>> => {
const definedAuthorOpts = (removeUndefinedKeys(authorOpts) as RequiredAuthorCrit);
const propResultsMap = Object.entries(definedAuthorOpts).reduce((acc: AuthorCritPropHelper, [k, v]) => {
const key = (k as keyof AuthorCriteria);
let ex;
if (Array.isArray(v)) {
ex = v.map(x => {
if (asUserNoteCriteria(x)) {
return userNoteCriteriaSummary(x);
}
return x;
});
} else {
ex = [v];
}
acc[key] = {
property: key,
behavior: include ? 'include' : 'exclude',
};
return acc;
}, {});
const {shadowBanned} = authorOpts;
if (shadowBanned !== undefined) {
try {
// @ts-ignore
await item.author.fetch();
// user is not shadowbanned
// if criteria specifies they SHOULD be shadowbanned then return false now
if (shadowBanned) {
propResultsMap.shadowBanned!.found = false;
propResultsMap.shadowBanned!.passed = false;
}
} catch (err: any) {
if (isStatusError(err) && err.statusCode === 404) {
// user is shadowbanned
// if criteria specifies they should not be shadowbanned then return false now
if (!shadowBanned) {
propResultsMap.shadowBanned!.found = true;
propResultsMap.shadowBanned!.passed = false;
}
} else {
throw err;
}
}
}
if (propResultsMap.shadowBanned === undefined || propResultsMap.shadowBanned.passed === undefined) {
try {
const authorName = getActivityAuthorName(item.author);
const keys = Object.keys(propResultsMap) as (keyof AuthorCriteria)[]
let shouldContinue = true;
for (const k of keys) {
if (k === 'shadowBanned') {
// we have already taken care of this with shadowban check above
continue;
}
const authorOptVal = definedAuthorOpts[k];
//if (authorOpts[k] !== undefined) {
switch (k) {
case 'name':
const nameVal = authorOptVal as RequiredAuthorCrit['name'];
const authPass = () => {
for (const n of nameVal) {
if (n.toLowerCase() === authorName.toLowerCase()) {
return true;
}
}
return false;
}
const authResult = authPass();
propResultsMap.name!.found = authorName;
propResultsMap.name!.passed = !((include && !authResult) || (!include && authResult));
if (!propResultsMap.name!.passed) {
shouldContinue = false;
}
break;
case 'flairCssClass':
const css = await item.author_flair_css_class;
const cssPass = () => {
// @ts-ignore
for (const c of authorOpts[k]) {
if (c === css) {
return true;
}
}
return false;
}
const cssResult = cssPass();
propResultsMap.flairCssClass!.found = css;
propResultsMap.flairCssClass!.passed = !((include && !cssResult) || (!include && cssResult));
if (!propResultsMap.flairCssClass!.passed) {
shouldContinue = false;
}
break;
case 'flairText':
const text = await item.author_flair_text;
const textPass = () => {
// @ts-ignore
for (const c of authorOpts[k]) {
if (c === text) {
return true;
}
}
return false;
};
const textResult = textPass();
propResultsMap.flairText!.found = text;
propResultsMap.flairText!.passed = !((include && !textResult) || (!include && textResult));
if (!propResultsMap.flairText!.passed) {
shouldContinue = false;
}
break;
case 'flairTemplate':
const templateId = await item.author_flair_template_id;
const templatePass = () => {
// @ts-ignore
for (const c of authorOpts[k]) {
if (c === templateId) {
return true;
}
}
return false;
};
const templateResult = templatePass();
propResultsMap.flairTemplate!.found = templateId;
propResultsMap.flairTemplate!.passed = !((include && !templateResult) || (!include && templateResult));
if (!propResultsMap.flairTemplate!.passed) {
shouldContinue = false;
}
break;
case 'isMod':
const mods: RedditUser[] = await item.subreddit.getModerators();
const isModerator = mods.some(x => x.name === authorName) || authorName.toLowerCase() === 'automoderator';
const modMatch = authorOpts.isMod === isModerator;
propResultsMap.isMod!.found = isModerator;
propResultsMap.isMod!.passed = !((include && !modMatch) || (!include && modMatch));
if (!propResultsMap.isMod!.passed) {
shouldContinue = false;
}
break;
case 'age':
const authorAge = dayjs.unix(await item.author.created);
const ageTest = compareDurationValue(parseDurationComparison(await authorOpts.age as string), authorAge);
propResultsMap.age!.found = authorAge.fromNow(true);
propResultsMap.age!.passed = !((include && !ageTest) || (!include && ageTest));
if (!propResultsMap.age!.passed) {
shouldContinue = false;
}
break;
case 'linkKarma':
// @ts-ignore
const tk = await item.author.total_karma as number;
const lkCompare = parseGenericValueOrPercentComparison(await authorOpts.linkKarma as string);
let lkMatch;
if (lkCompare.isPercent) {
lkMatch = comparisonTextOp(item.author.link_karma / tk, lkCompare.operator, lkCompare.value / 100);
} else {
lkMatch = comparisonTextOp(item.author.link_karma, lkCompare.operator, lkCompare.value);
}
propResultsMap.linkKarma!.found = tk;
propResultsMap.linkKarma!.passed = !((include && !lkMatch) || (!include && lkMatch));
if (!propResultsMap.linkKarma!.passed) {
shouldContinue = false;
}
break;
case 'commentKarma':
// @ts-ignore
const ck = await item.author.total_karma as number;
const ckCompare = parseGenericValueOrPercentComparison(await authorOpts.commentKarma as string);
let ckMatch;
if (ckCompare.isPercent) {
ckMatch = comparisonTextOp(item.author.comment_karma / ck, ckCompare.operator, ckCompare.value / 100);
} else {
ckMatch = comparisonTextOp(item.author.comment_karma, ckCompare.operator, ckCompare.value);
}
propResultsMap.commentKarma!.found = ck;
propResultsMap.commentKarma!.passed = !((include && !ckMatch) || (!include && ckMatch));
if (!propResultsMap.commentKarma!.passed) {
shouldContinue = false;
}
break;
case 'totalKarma':
// @ts-ignore
const totalKarma = await item.author.total_karma as number;
const tkCompare = parseGenericValueComparison(await authorOpts.totalKarma as string);
if (tkCompare.isPercent) {
throw new SimpleError(`'totalKarma' value on AuthorCriteria cannot be a percentage`);
}
const tkMatch = comparisonTextOp(totalKarma, tkCompare.operator, tkCompare.value);
propResultsMap.totalKarma!.found = totalKarma;
propResultsMap.totalKarma!.passed = !((include && !tkMatch) || (!include && tkMatch));
if (!propResultsMap.totalKarma!.passed) {
shouldContinue = false;
}
break;
case 'verified':
const verified = await item.author.has_verified_mail;
const vMatch = verified === authorOpts.verified as boolean;
propResultsMap.verified!.found = verified;
propResultsMap.verified!.passed = !((include && !vMatch) || (!include && vMatch));
if (!propResultsMap.verified!.passed) {
shouldContinue = false;
}
break;
case 'description':
// @ts-ignore
const desc = await item.author.subreddit?.display_name.public_description;
const dVals = authorOpts[k] as string[];
let passed = false;
let passReg;
for (const val of dVals) {
let reg = parseStringToRegex(val, 'i');
if (reg === undefined) {
reg = parseStringToRegex(`/.*${escapeRegex(val.trim())}.*/`, 'i');
if (reg === undefined) {
throw new SimpleError(`Could not convert 'description' value to a valid regex: ${authorOpts[k] as string}`);
}
}
if (reg.test(desc)) {
passed = true;
passReg = reg.toString();
break;
}
}
propResultsMap.description!.found = typeof desc === 'string' ? truncateStringToLength(50)(desc) : desc;
propResultsMap.description!.passed = !((include && !passed) || (!include && passed));
if (!propResultsMap.description!.passed) {
shouldContinue = false;
} else {
propResultsMap.description!.reason = `Matched with: ${passReg as string}`;
}
break;
case 'userNotes':
const notes = await userNotes.getUserNotes(item.author);
let foundNoteResult: string[] = [];
const notePass = () => {
for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) {
const {count = '>= 1', search = 'current', type} = noteCriteria;
const {
value,
operator,
isPercent,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const order = extra.includes('asc') ? 'ascending' : 'descending';
switch (search) {
case 'current':
if (notes.length > 0) {
const currentNoteType = notes[notes.length - 1].noteType;
foundNoteResult.push(`Current => ${currentNoteType}`);
if (currentNoteType === type) {
return true;
}
} else {
foundNoteResult.push('No notes present');
}
break;
case 'consecutive':
let orderedNotes = notes;
if (order === 'descending') {
orderedNotes = [...notes];
orderedNotes.reverse();
}
let currCount = 0;
for (const note of orderedNotes) {
if (note.noteType === type) {
currCount++;
} else {
currCount = 0;
}
if (isPercent) {
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
}
foundNoteResult.push(`Found ${currCount} ${type} consecutively`);
if (comparisonTextOp(currCount, operator, value)) {
return true;
}
}
break;
case 'total':
const filteredNotes = notes.filter(x => x.noteType === type);
if (isPercent) {
// avoid divide by zero
const percent = notes.length === 0 ? 0 : filteredNotes.length / notes.length;
foundNoteResult.push(`${formatNumber(percent)}% are ${type}`);
if (comparisonTextOp(percent, operator, value / 100)) {
return true;
}
} else {
foundNoteResult.push(`${filteredNotes.length} are ${type}`);
if (comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
return true;
}
}
break;
}
}
return false;
}
const noteResult = notePass();
propResultsMap.userNotes!.found = foundNoteResult.join(' | ');
propResultsMap.userNotes!.passed = !((include && !noteResult) || (!include && noteResult));
if (!propResultsMap.userNotes!.passed) {
shouldContinue = false;
}
break;
}
//}
if (!shouldContinue) {
break;
}
}
} catch (err: any) {
if (isStatusError(err) && err.statusCode === 404) {
throw new SimpleError('Reddit returned a 404 while trying to retrieve User profile. It is likely this user is shadowbanned.');
} else {
throw err;
}
}
}
// gather values and determine overall passed
const propResults = Object.values(propResultsMap);
const passed = propResults.filter(x => typeof x.passed === 'boolean').every(x => x.passed === true);
return {
behavior: include ? 'include' : 'exclude',
criteria: authorOpts,
propertyResults: propResults,
passed,
};
}
export interface ItemContent {
submissionTitle: string,
content: string,
@@ -853,13 +490,17 @@ export const activityIsRemoved = (item: Submission | Comment): boolean => {
}
export const activityIsFiltered = (item: Submission | Comment): boolean => {
if (item instanceof Submission) {
// when automod filters a post it gets this category
return item.banned_at_utc !== null && item.removed_by_category === 'automod_filtered';
if(item.can_mod_post) {
if (item instanceof Submission) {
// when automod filters a post it gets this category
return item.banned_at_utc !== null && item.removed_by_category === 'automod_filtered';
}
// when automod filters a comment item.removed === false
// so if we want to processing filtered comments we need to check for this
return item.banned_at_utc !== null && !item.removed;
}
// when automod filters a comment item.removed === false
// so if we want to processing filtered comments we need to check for this
return item.banned_at_utc !== null && !item.removed;
// not possible to know if its filtered if user isn't a mod so always return false
return false;
}
export const activityIsDeleted = (item: Submission | Comment): boolean => {

View File

@@ -170,7 +170,7 @@ const webClient = async (options: OperatorConfig) => {
const logger = getLogger({defaultLabel: 'Web', ...options.logging}, 'Web');
logger.stream().on('log', (log: LogInfo) => {
emitter.emit('log', log[MESSAGE]);
emitter.emit('log', log);
});
if (await tcpUsed.check(port)) {
@@ -769,7 +769,7 @@ const webClient = async (options: OperatorConfig) => {
return res.render('noAccess');
}
return res.redirect(`/?instance=${accessibleInstance.friendly}`);
return res.redirect(`/?instance=${accessibleInstance.getName()}`);
}
const instance = cmInstances.find(x => x.getName() === req.query.instance);
req.instance = instance;
@@ -907,7 +907,8 @@ const webClient = async (options: OperatorConfig) => {
instanceId: (req.instance as CMInstanceInterface).friendly,
isOperator: isOp,
system: isOp ? {
logs: resp.system.logs,
// @ts-ignore
logs: resp.system.logs.map((x: LogInfo) => formatLogLineToHtml(formatter.transform(x)[MESSAGE] as string, x.timestamp)),
} : undefined,
operators: instance.operators.join(', '),
operatorDisplay: instance.operatorDisplay,
@@ -988,7 +989,7 @@ const webClient = async (options: OperatorConfig) => {
}).json() as [any];
const actionedEvents = resp.map((x: ActionedEvent) => {
const {timestamp, activity: {peek, link, ...restAct}, runResults = [], ...rest} = x;
const {timestamp, activity: {peek, link, ...restAct}, runResults = [], dispatchSource, ...rest} = x;
const time = dayjs(timestamp).local().format('YY-MM-DD HH:mm:ss z');
const formattedPeek = Autolinker.link(peek.replace(`https://reddit.com${link}`, ''), {
email: false,
@@ -1048,7 +1049,12 @@ const webClient = async (options: OperatorConfig) => {
...formatFilterData(summ)
}
});
let rrSource = dispatchSource === undefined ? dispatchSource : {
...dispatchSource,
queuedAt: dayjs.unix(dispatchSource.queuedAt).local().format('YY-MM-DD HH:mm:ss z')
}
return {
dispatchSource: rrSource,
...rest,
timestamp: time,
activity: {
@@ -1121,16 +1127,6 @@ const webClient = async (options: OperatorConfig) => {
clearSockStreams(socket.id);
socket.join(session.id);
// setup general web log event
const webLogListener = (log: string) => {
const subName = parseSubredditLogName(log);
if((subName === undefined || user.clientData?.webOperator === true) && isLogLineMinLevel(log, session.level as string)) {
io.to(session.id).emit('webLog', formatLogLineToHtml(log));
}
}
emitter.on('log', webLogListener);
socketListeners.set(socket.id, [...(socketListeners.get(socket.id) || []), webLogListener]);
socket.on('viewing', (data) => {
if(user !== undefined) {
const {subreddit, bot: botVal} = data;
@@ -1171,11 +1167,17 @@ const webClient = async (options: OperatorConfig) => {
const bot = cmInstances.find(x => x.getName() === session.botId);
if(bot !== undefined) {
// web log listener for bot specifically
const botWebLogListener = (log: string) => {
const subName = parseSubredditLogName(log);
const instanceName = parseInstanceLogName(log);
if((subName !== undefined || instanceName !== undefined) && isLogLineMinLevel(log, session.level as string) && (session.botId?.toLowerCase() === instanceName || (subName !== undefined && subName.toLowerCase().includes(user.name.toLowerCase())))) {
io.to(session.id).emit('log', formatLogLineToHtml(log));
const botWebLogListener = (log: LogInfo) => {
const {subreddit, instance, user: userFromLog} = log;
if((subreddit !== undefined || instance !== undefined)
&& isLogLineMinLevel(log, session.level as string)
&& (session.botId?.toLowerCase() === instance
|| user.clientData?.webOperator === true
|| (userFromLog !== undefined && userFromLog.toLowerCase().includes(user.name.toLowerCase()))
)) {
// @ts-ignore
const formattedMessage = formatLogLineToHtml(formatter.transform(log)[MESSAGE], log.timestamp);
io.to(session.id).emit('log', {...log, formattedMessage});
}
}
emitter.on('log', botWebLogListener);

View File

@@ -57,6 +57,7 @@ const sub: SubredditDataResponse = {
queueState: runningState,
queuedActivities: 0,
runningActivities: 0,
delayedItems: [],
softLimit: 0,
startedAt: "-",
startedAtHuman: "-",

View File

@@ -23,6 +23,7 @@ export interface SubredditDataResponse {
indicator: string
queuedActivities: number
runningActivities: number
delayedItems: any[]
maxWorkers: number
subMaxWorkers: number
globalMaxWorkers: number

View File

@@ -64,6 +64,7 @@ const action = async (req: Request, res: Response) => {
await manager.firehose.push({
activity: a,
options: {
source: 'user',
force: true,
}
});
@@ -74,6 +75,7 @@ const action = async (req: Request, res: Response) => {
await manager.firehose.push({
activity: a,
options: {
source: 'user',
force: true
}
});

View File

@@ -126,10 +126,33 @@ const action = async (req: Request, res: Response) => {
// will run dryrun if specified or if running activity on subreddit it does not belong to
const dr: boolean | undefined = (dryRun || manager.subreddit.display_name !== sub) ? true : undefined;
manager.logger.info(`/u/${userName} running${dr === true ? ' DRY RUN ' : ' '}check on${manager.subreddit.display_name !== sub ? ' FOREIGN ACTIVITY ' : ' '}${url}`, {user: userName, subreddit});
await manager.handleActivity(activity, {dryRun: dr, force: true})
manager.logger.info(`/u/${userName} Queued ${dr === true ? 'DRY RUN ' : ''}check on ${manager.subreddit.display_name !== sub ? 'FOREIGN ACTIVITY ' : ''}${url}`, {user: userName, subreddit});
await manager.firehose.push({activity, options: {dryRun: dr, force: true, source: 'user'}})
}
res.send('OK');
};
export const actionRoute = [authUserCheck(), botRoute(), booleanMiddle(['dryRun']), action];
const cancelDelayed = async (req: Request, res: Response) => {
const {id} = req.query as any;
const {name: userName} = req.user as Express.User;
if(req.manager?.resources === undefined) {
req.manager?.logger.error('Subreddit does not have delayed items!', {user: userName});
return res.status(400).send();
}
const delayedItem = req.manager.resources.delayedItems.find(x => x.id === id);
if(delayedItem === undefined) {
req.manager?.logger.error(`No delayed items exists with the id ${id}`, {user: userName});
return res.status(400).send();
}
req.manager.resources.delayedItems = req.manager.resources.delayedItems.filter(x => x.id !== id);
req.manager?.logger.info(`Remove Delayed Item '${delayedItem.id}'`, {user: userName});
return res.send('OK');
};
export const cancelDelayedRoute = [authUserCheck(), botRoute(), subredditRoute(true), cancelDelayed];

View File

@@ -28,6 +28,7 @@ const liveStats = () => {
const sd = {
queuedActivities: m.queue.length(),
runningActivities: m.queue.running(),
delayedItems: m.getDelayedSummary(),
maxWorkers: m.queue.concurrency,
subMaxWorkers: m.subMaxWorkers || bot.maxWorkers,
globalMaxWorkers: bot.maxWorkers,
@@ -37,6 +38,27 @@ const liveStats = () => {
},
stats: await m.getStats(),
}
if (m.startedAt !== undefined) {
const dur = dayjs.duration(dayjs().diff(m.startedAt));
if (sd.stats.cache.totalRequests > 0) {
const minutes = dur.asMinutes();
if (minutes < 10) {
sd.stats.cache.requestRate = formatNumber((10 / minutes) * sd.stats.cache.totalRequests, {
toFixed: 0,
round: {enable: true, indicate: true}
});
} else {
sd.stats.cache.requestRate = formatNumber(sd.stats.cache.totalRequests / (minutes / 10), {
toFixed: 0,
round: {enable: true, indicate: true}
});
}
} else {
sd.stats.cache.requestRate = 0;
}
}
subManagerData.push(sd);
}
const totalStats = subManagerData.reduce((acc, curr) => {
@@ -63,6 +85,8 @@ const liveStats = () => {
globalMaxWorkers: acc.globalMaxWorkers + curr.globalMaxWorkers,
runningActivities: acc.runningActivities + curr.runningActivities,
queuedActivities: acc.queuedActivities + curr.queuedActivities,
// @ts-ignore
delayedItems: acc.delayedItems.concat(curr.delayedItems)
};
}, {
checks: {
@@ -87,6 +111,7 @@ const liveStats = () => {
globalMaxWorkers: 0,
runningActivities: 0,
queuedActivities: 0,
delayedItems: [],
});
const {
checks,
@@ -95,6 +120,7 @@ const liveStats = () => {
subMaxWorkers,
runningActivities,
queuedActivities,
delayedItems,
...rest
} = totalStats;
@@ -131,6 +157,7 @@ const liveStats = () => {
subMaxWorkers,
runningActivities,
queuedActivities,
delayedItems,
botState: {
state: RUNNING,
causedBy: SYSTEM
@@ -184,8 +211,8 @@ const liveStats = () => {
startedAt: bot.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
running: bot.running,
error: bot.error,
...opStats(bot),
},
...allManagerData,
};
return res.json(data);
} else {
@@ -199,6 +226,7 @@ const liveStats = () => {
permissions: await manager.getModPermissions(),
queuedActivities: manager.queue.length(),
runningActivities: manager.queue.running(),
delayedItems: manager.getDelayedSummary(),
maxWorkers: manager.queue.concurrency,
subMaxWorkers: manager.subMaxWorkers || bot.maxWorkers,
globalMaxWorkers: bot.maxWorkers,
@@ -218,6 +246,9 @@ const liveStats = () => {
startedAt: 'Not Started',
startedAtHuman: 'Not Started',
delayBy: manager.delayBy === undefined ? 'No' : `Delayed by ${manager.delayBy} sec`,
system: {
running: bot.running,
}
};
// TODO replace indicator data with js on client page
let indicator;

View File

@@ -83,6 +83,7 @@ const status = () => {
permissions: [],
queuedActivities: m.queue.length(),
runningActivities: m.queue.running(),
delayedItems: m.getDelayedSummary(),
maxWorkers: m.queue.concurrency,
subMaxWorkers: m.subMaxWorkers || bot.maxWorkers,
globalMaxWorkers: bot.maxWorkers,
@@ -163,6 +164,8 @@ const status = () => {
globalMaxWorkers: acc.globalMaxWorkers + curr.globalMaxWorkers,
runningActivities: acc.runningActivities + curr.runningActivities,
queuedActivities: acc.queuedActivities + curr.queuedActivities,
// @ts-ignore
delayedItems: acc.delayedItems.concat(curr.delayedItems)
};
}, {
checks: {
@@ -187,6 +190,7 @@ const status = () => {
globalMaxWorkers: 0,
runningActivities: 0,
queuedActivities: 0,
delayedItems: [],
});
const {
checks,
@@ -195,6 +199,7 @@ const status = () => {
subMaxWorkers,
runningActivities,
queuedActivities,
delayedItems,
...rest
} = totalStats;
@@ -239,6 +244,7 @@ const status = () => {
subMaxWorkers,
runningActivities,
queuedActivities,
delayedItems,
botState: {
state: RUNNING,
causedBy: SYSTEM

View File

@@ -11,13 +11,13 @@ import tcpUsed from 'tcp-port-used';
import {getLogger} from "../../Utils/loggerFactory";
import LoggedError from "../../Utils/LoggedError";
import {Invokee, LogInfo, OperatorConfigWithFileContext} from "../../Common/interfaces";
import {Invokee, LogInfo, OperatorConfigWithFileContext, RUNNING, STOPPED} from "../../Common/interfaces";
import http from "http";
import {heartbeat} from "./routes/authenticated/applicationRoutes";
import logs from "./routes/authenticated/user/logs";
import status from './routes/authenticated/user/status';
import liveStats from './routes/authenticated/user/liveStats';
import {actionedEventsRoute, actionRoute, configRoute, configLocationRoute, deleteInviteRoute, addInviteRoute, getInvitesRoute} from "./routes/authenticated/user";
import {actionedEventsRoute, actionRoute, configRoute, configLocationRoute, deleteInviteRoute, addInviteRoute, getInvitesRoute, cancelDelayedRoute} from "./routes/authenticated/user";
import action from "./routes/authenticated/user/action";
import {authUserCheck, botRoute} from "./middleware";
import {opStats} from "../Common/util";
@@ -26,6 +26,7 @@ import addBot from "./routes/authenticated/user/addBot";
import ServerUser from "../Common/User/ServerUser";
import {SimpleError} from "../../Utils/Errors";
import {ErrorWithCause} from "pony-cause";
import {Manager} from "../../Subreddit/Manager";
const server = addAsync(express());
server.use(bodyParser.json());
@@ -146,7 +147,27 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
const resp = [];
let index = 1;
for(const b of bots) {
resp.push({name: b.botName ?? `Bot ${index}`, data: await opStats(b)});
resp.push({name: b.botName ?? `Bot ${index}`, data: {
status: b.running ? 'RUNNING' : 'NOT RUNNING',
indicator: b.running ? 'green' : 'red',
running: b.running,
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
error: b.error,
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
let indicator;
if (manager.botState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
indicator = 'green';
} else if (manager.botState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
indicator = 'red';
} else {
indicator = 'yellow';
}
return {
name: manager.displayLabel,
indicator,
};
}),
}});
index++;
}
return res.json(resp);
@@ -178,6 +199,8 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
server.deleteAsync('/bot/invite', ...deleteInviteRoute);
server.deleteAsync('/delayed', ...cancelDelayedRoute);
const initBot = async (causedBy: Invokee = 'system') => {
if (app !== undefined) {
logger.info('A bot instance already exists. Attempting to stop event/queue processing first before building new bot.');

File diff suppressed because one or more lines are too long

View File

@@ -35,6 +35,19 @@
<div>
<a class="activityLink font-semibold" target="_blank" href="https://reddit.com<%= eRes.activity.link%>"><%= eRes.activity.type === 'comment' ? 'Comment' : 'Submission' %></a>
by <a class="activityLink" target="_blank" href="https://reddit.com/u/<%= eRes.activity.author%>"><%= eRes.activity.author%></a>
<% if(eRes.dispatchSource !== undefined) { %>
<span class="has-tooltip ml-2">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 space-y-3 p-2 text-left'>
<ul class="list-inside list-disc">
<li>Dispatched By: <i><%= eRes.dispatchSource.action %><%= eRes.dispatchSource.identifier !== undefined ? ` | ${eRes.dispatchSource.identifier}` : ''%></i></li>
<li>Queued At: <i><%= eRes.dispatchSource.queuedAt %></i></li>
<li>Delayed For: <i><%= eRes.dispatchSource.delay %></i></li>
<li>Initial Goto:<i><%= eRes.dispatchSource.goto %></i></li>
</ul>
</span>
(<span class="cursor-help underline" style="text-decoration-style: dotted">Dispatched by <%= eRes.dispatchSource.action%><%= eRes.dispatchSource.identifier !== undefined ? ` | ${eRes.dispatchSource.identifier}` : ''%></span>)
</span>
<% } %>
</div>
<div class="font-semibold flex items-center flex-end">
<a class="activityLink mr-1" target="_blank" href="https://reddit.com/<%= eRes.activity.subreddit %>"><%= eRes.activity.subreddit %></a>
@@ -44,15 +57,18 @@
</div>
<div class="border-t-2 border-gray-500 triggeredState <%= eRes.triggered ? 'triggered' : 'notTriggered'%>">
<div class="m-4 p-2 px-4">
<blockquote class="ml-3 italic" cite="https://reddit.com<%= eRes.activity.link%>">
"<%- eRes.activity.peek %>"
</blockquote>
<% if(eRes.parentSubmission !== undefined) { %>
<div class="my-3">in a <a class="activityLink inline" href="https://reddit.com<%= eRes.parentSubmission.link%>">Submission</a> by <a class="activityLink inline" target="_blank" href="https://reddit.com/u/<%= eRes.parentSubmission.author%>">/u/<%= eRes.parentSubmission.author%></a> titled:</div>
<blockquote class="ml-3 mb-2 italic" cite="https://reddit.com<%= eRes.parentSubmission.link%>">
"<%= eRes.parentSubmission.peek%>"
</blockquote>
<% } %>
<div class="ml-5">
<blockquote class="ml-4 italic" cite="https://reddit.com<%= eRes.activity.link%>">
"<%- eRes.activity.peek %>"
</blockquote>
<% if(eRes.parentSubmission !== undefined) { %>
<div class="my-3">in a <a class="activityLink inline" href="https://reddit.com<%= eRes.parentSubmission.link%>">Submission</a> by <a class="activityLink inline" target="_blank" href="https://reddit.com/u/<%= eRes.parentSubmission.author%>">/u/<%= eRes.parentSubmission.author%></a> titled:</div>
<blockquote class="ml-4 mb-2 italic" cite="https://reddit.com<%= eRes.parentSubmission.link%>">
"<%= eRes.parentSubmission.peek%>"
</blockquote>
<% } %>
</div>
</div>
<% eRes.runResults.forEach(function (runSum, index) { %>
<div class="m-4 p-2 px-4 space-y-2 bg-gray-600 triggeredStateWrapper">

View File

@@ -210,9 +210,14 @@
<label for="modself"><span class="font-mono font-semibold">modself</span> for the bot
to be able to accept moderator invitations programmatically</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="modnote" name="modnote" checked>
<label for="modnote"><span class="font-mono font-semibold">modnote</span> for the bot to
be able to read and write Mod Notes<</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="modlog" name="modlog">
<label for="modlog"><span class="font-mono font-semibold">modlog</span> for the bot to
<label for="modlog"><span class="font-mono font-semibold">modlog</span> for the bot to be
able to read the moderation log (not currently implemented)</label>
</div>
</div>

View File

@@ -128,9 +128,14 @@
<label for="modself"><span class="font-mono font-semibold">modself</span> for the bot
to be able to accept moderator invitations programmatically</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="modnote" name="modnote" disabled>
<label for="modnote"><span class="font-mono font-semibold">modnote</span> for the bot to
be able to read and write Mod Notes</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="modlog" name="modlog" disabled>
<label for="modlog"><span class="font-mono font-semibold">modlog</span> for the bot to
<label for="modlog"><span class="font-mono font-semibold">modlog</span> for the bot to be
able to read the moderation log (not currently implemented)</label>
</div>
</div>

View File

@@ -19,7 +19,7 @@
<%= data.system.name %>
</a>
<% if ((data.system.name === 'All' && isOperator) || data.system.name !== 'All') { %>
<span class="inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.system.running ? 'green' : 'red' %>-400 rounded-full"></span>
<span data-bot="<%= data.system.name %>" class="botTabStatus inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.system.running ? 'green' : 'red' %>-400 rounded-full"></span>
<% } %>
</span>
</li>

View File

@@ -6,24 +6,27 @@
<script type="text/javascript" src="https://cdn.statuspage.io/se-v2.js"></script>
<script>
// https://www.redditstatus.com/api#status
var sp = new StatusPage.page({ page : '2kbc0d48tv3j' });
sp.status({
success : function(data) {
console.log(data.status.indicator);
switch(data.status.indicator){
case 'minor':
document.querySelector('#redditStatus').innerHTML = '<span class="iconify-inline yellow" data-icon="ep:warning-filled"></span>';
break;
case 'none':
document.querySelector('#redditStatus').innerHTML = '<span class="iconify-inline green" data-icon="ep:circle-check-filled"></span>';
break;
default:
document.querySelector('#redditStatus').innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
break;
var statusEl = document.querySelector('#redditStatus');
if(statusEl !== null) {
var sp = new StatusPage.page({ page : '2kbc0d48tv3j' });
sp.status({
success : function(data) {
//console.log(data.status.indicator);
switch(data.status.indicator){
case 'minor':
statusEl.innerHTML = '<span class="iconify-inline yellow" data-icon="ep:warning-filled"></span>';
break;
case 'none':
statusEl.innerHTML = '<span class="iconify-inline green" data-icon="ep:circle-check-filled"></span>';
break;
default:
dstatusEl.innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
break;
}
// data.page.updated_at
// data.status.indicator => none, minor, major, or critical
// data.status.description
}
// data.page.updated_at
// data.status.indicator => none, minor, major, or critical
// data.status.description
}
});
});
}
</script>

View File

@@ -11,7 +11,7 @@
<%= data.name === 'All' ? 'All Subreddits' : data.name %>
</a>
<% if ((data.name === 'All' && isOperator) || data.name !== 'All') { %>
<span class="inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.indicator %>-400 rounded-full"></span>
<span data-bot="<%= botData.system.name %>" data-subreddit="<%= data.name %>" class="subredditTabStatus inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.indicator %>-400 rounded-full"></span>
<% } %>
</span>
</li>

View File

@@ -81,7 +81,7 @@
</span>
</label>
<div class="flex items-center justify-between">
<span class="font-semibold"><%= `${data.botState.state}${data.botState.causedBy === 'system' ? '' : ' (user)'}` %></span>
<span class="font-semibold botState"><%= `${data.botState.state}${data.botState.causedBy === 'system' ? '' : ' (user)'}` %></span>
<div class="flex items-center flex-end">
<div>
<a class="action" data-action="start" data-type="bot"
@@ -114,7 +114,7 @@
</span>
</label>
<div class="flex items-center justify-between">
<span class="font-semibold"><%= `${data.queueState.state}${data.queueState.causedBy === 'system' ? '' : ' (user)'}` %></span>
<span class="font-semibold queueState"><%= `${data.queueState.state}${data.queueState.causedBy === 'system' ? '' : ' (user)'}` %></span>
<div class="flex items-center flex-end">
<div>
<a class="action" data-action="start" data-type="queue"
@@ -148,7 +148,7 @@
</span>
</label>
<div class="flex items-center justify-between">
<span class="font-semibold"><%= `${data.eventsState.state}${data.eventsState.causedBy === 'system' ? '' : ' (user)'}` %></span>
<span class="font-semibold eventsState"><%= `${data.eventsState.state}${data.eventsState.causedBy === 'system' ? '' : ' (user)'}` %></span>
<div class="flex items-center flex-end">
<div>
<a class="action" data-action="start" data-type="event"
@@ -174,11 +174,11 @@
<span class="cursor-help underline modPermissionsCount" style="text-decoration-style: dotted"><%= data.permissions.length %></span>
</span>
<label>Slow Mode</label>
<span><%= data.delayBy %></span>
<span class="delayBy"><%= data.delayBy %></span>
<% } %>
<% if (data.name === 'All') { %>
<label>Status</label>
<span class="font-semibold"><%= bot.system.running ? 'ONLINE' : 'OFFLINE' %></span>
<span class="font-semibold botStatus"><%= bot.system.running ? 'ONLINE' : 'OFFLINE' %></span>
<label>Account</label>
<span><a href="https://reddit.com/<%= bot.system.account %>"><%= bot.system.account %></a></span>
<label>Uptime</label>
@@ -230,7 +230,16 @@
</span>
</span>
</label>
<span><%= `${data.runningActivities} Processing / ${data.queuedActivities} Queued` %></span>
<span>
<span class="runningActivities"><%= data.runningActivities %></span> Processing /
<span class="queuedActivities"><%= data.queuedActivities %></span> Queued /
<span class="has-tooltip">
<span style="margin-top:35px"
class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 space-y-3 p-2 text-left delayedItemsList'>
</span>
<span class="underline" style="text-decoration-style: dotted"><span class="delayedItemsCount"><%= data.delayedItems.length %></span> Delayed</span>
</span>
</span>
<label>Operated By</label>
<span><%= operatorDisplay %></span>
<% if (data.name === 'All' && isOperator) { %>
@@ -252,7 +261,7 @@
<% } else %>
</div>
<% if (data.name !== 'All') { %>
<ul class="list-disc list-inside mt-4">
<ul class="list-disc list-inside mt-4 pollingInfo">
<% data.pollingInfo.forEach(function (i){ %>
<li>Polling <%- i %></li>
<% }) %>
@@ -270,7 +279,7 @@
<label>Soft Limit</label>
<span>< <span id="softLimit"><%= data.softLimit %></span></span>
<label>Hard Limit</label>
<span>< <span id="softLimit"><%= data.hardLimit %></span></span>
<span>< <span id="hardLimit"><%= data.hardLimit %></span></span>
<label>Api Nanny</label>
<span><b><span id="nannyMode"><%= data.nannyMode %></span></b></span>
<label>Api Usage</label>
@@ -324,38 +333,36 @@
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-700 ">
<h4>Config
<span>
<span class="has-tooltip">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 wikiRevision'>
<%= data.wikiRevision %>
</span>
(Revised <%= data.wikiRevisionHuman %>)
(Revised <span class="wikiRevisionHuman"><%= data.wikiRevisionHuman %></span>)
</span>
</h4>
</div>
<div class="p-4">
<div class="stats">
<label>Valid</label>
<span class="font-semibold"><%= data.validConfig %></span>
<span class="font-semibold validConfig"><%= data.validConfig %></span>
<label>Checks</label>
<span><%= data.checks.submissions %> Submission | <%= data.checks.comments %> Comment </span>
<span><span class="submissionCheckCount"><%= data.checks.submissions %></span> Submission | <span class="commentCheckCount"><%= data.checks.comments %></span> Comment </span>
<label>Dry Run</label>
<span><%= data.dryRun %></span>
<label>Updated</label>
<span class="has-tooltip">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 startedAt'>
<%= data.startedAt %>
</span>
<%= data.startedAtHuman %>
</span>
<span class="startedAtHuman"><%= data.startedAtHuman %></span>
</span>
<label>Checked</label>
<span>
<span class="has-tooltip">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 wikiLastCheck'>
<%= data.wikiLastCheck %>
</span>
<%= data.wikiLastCheckHuman %>
<span class="wikiLastCheckHuman"><%= data.wikiLastCheckHuman %></span>
</span>
<a class="action" data-action="reload"
data-subreddit="<%= data.name %>"
@@ -392,7 +399,7 @@
<div data-subreddit="<%= data.name %>"
class="stats botStats reloadStats mb-2">
<label>Events</label>
<span>
<span class="eventsCount">
<%= data.stats.historical.lastReload.eventsCheckedTotal === undefined ? '-' : data.stats.historical.lastReload.eventsCheckedTotal %>
</span>
<label>Checks</label>
@@ -400,7 +407,7 @@
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span><%= data.stats.historical.lastReload.checksTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.lastReload.checksRunTotal %></span> Run / <span><%= data.stats.historical.lastReload.checksFromCacheTotal %></span> Cached
</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><%= data.stats.historical.lastReload.checksTriggeredTotal %> Triggered</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><span class="checksCount"><%= data.stats.historical.lastReload.checksTriggeredTotal %></span> Triggered</span>
</span>
<label>Rules</label>
@@ -409,16 +416,16 @@
<span><%= data.stats.historical.lastReload.rulesTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.lastReload.rulesCachedTotal %></span> Cached / <span><%= data.stats.historical.lastReload.rulesRunTotal %></span> Run
</span>
<span class="cursor-help cursor-help underline" style="text-decoration-style: dotted">
<span><%= data.stats.historical.lastReload.rulesTriggeredTotal %></span> Triggered</span>
<span class="rulesCount"><%= data.stats.historical.lastReload.rulesTriggeredTotal %></span> Triggered</span>
</span>
<label>Actions</label>
<span><%= data.stats.historical.lastReload.actionsRunTotal === undefined ? '0' : data.stats.historical.lastReload.actionsRunTotal %> Run</span>
<span><span class="actionsCount"><%= data.stats.historical.lastReload.actionsRunTotal === undefined ? '0' : data.stats.historical.lastReload.actionsRunTotal %></span> Run</span>
</div>
<% } %>
<div data-subreddit="<%= data.name %>" class="stats botStats allStats mb-2">
<label>Events</label>
<span>
<span class="eventsCount">
<%= data.stats.historical.allTime.eventsCheckedTotal %>
</span>
<label>Checks</label>
@@ -426,7 +433,7 @@
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span><%= data.stats.historical.allTime.checksTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.allTime.checksRunTotal %></span> Run / <span><%= data.stats.historical.allTime.checksFromCacheTotal %></span> Cached
</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><%= data.stats.historical.allTime.checksTriggeredTotal %> Triggered</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><span class="checksCount"><%= data.stats.historical.allTime.checksTriggeredTotal %></span> Triggered</span>
</span>
<label>Rules</label>
@@ -434,12 +441,12 @@
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span><%= data.stats.historical.allTime.rulesTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.allTime.rulesCachedTotal %></span> Cached / <span><%= data.stats.historical.allTime.rulesRunTotal %></span> Run
</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><span><%= data.stats.historical.allTime.rulesTriggeredTotal %></span> Triggered</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><span class="rulesCount"><%= data.stats.historical.allTime.rulesTriggeredTotal %></span> Triggered</span>
</span>
<label>Actions</label>
<span>
<span><%= data.stats.historical.allTime.actionsRunTotal === undefined ? '0' : data.stats.historical.allTime.actionsRunTotal %> Run</span>
<span><span class="actionsCount"><%= data.stats.historical.allTime.actionsRunTotal === undefined ? '0' : data.stats.historical.allTime.actionsRunTotal %></span> Run</span>
</span>
</div>
<% if (data.name !== 'All') { %>
@@ -681,9 +688,15 @@
<script src="https://unpkg.com/dayjs@1.10.7/dayjs.min.js" crossorigin="anonymous"></script>
<script src="https://unpkg.com/dayjs@1.10.7/plugin/advancedFormat.js"></script>
<script src="https://unpkg.com/dayjs@1.10.7/plugin/timezone.js"></script>
<script src="https://unpkg.com/dayjs@1.10.7/plugin/duration.js"></script>
<script src="https://unpkg.com/dayjs@1.10.7/plugin/relativeTime.js"></script>
<script src="https://unpkg.com/dayjs@1.10.7/plugin/isSameOrAfter.js"></script>
<script>
dayjs.extend(window.dayjs_plugin_timezone)
dayjs.extend(window.dayjs_plugin_advancedFormat)
dayjs.extend(window.dayjs_plugin_duration)
dayjs.extend(window.dayjs_plugin_relativeTime)
dayjs.extend(window.dayjs_plugin_isSameOrAfter)
window.formattedTime = (short, full) => `<span class="has-tooltip"><span style="margin-top:35px" class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black space-y-3 p-2 text-left'>${full}</span><span>${short}</span></span>`;
window.formatLogLineToHtml = (log, timestamp = undefined) => {
const val = typeof log === 'string' ? log : log['MESSAGE'];
@@ -838,6 +851,11 @@
wrapper.classList.remove('border');
wrapper.classList.add('border-2');
// special case for system
if(bot === 'system') {
document.querySelector('[data-bot="system"].sub').classList.add('seen');
}
if ('URLSearchParams' in window) {
var searchParams = new URLSearchParams(window.location.search)
searchParams.set("bot", bot);
@@ -1067,27 +1085,44 @@
bufferTimeout = setTimeout(() => {flushLogs();}, 1000);
}
});
socket.on("webLog", data => {
console.log(data);
});
socket.on("logClear", data => {
data.forEach((obj) => {
const n = obj.name === 'all' ? 'All' : obj.name;
document.querySelector(`[data-subreddit="${n}"].logs`).innerHTML = obj.logs;
})
});
const subIndicators = ['red', 'green', 'yellow'];
socket.on('opStats', (resp) => {
for(const b of resp) {
const {name, data} = b;
for (const [k, v] of Object.entries(data)) {
document.querySelector(`[data-bot="${name}"].sub #${k}`).innerHTML = v;
const botTab = document.querySelector(`[data-bot="${name}"] .botTabStatus`);
if(botTab !== null) {
const currentStatusClass = `bg-${data.running ? 'green' : 'red'}-400`;
const oppositeStatusClass = `bg-${data.running ? 'red' : 'green'}-400`;
if(!botTab.classList.contains(currentStatusClass)) {
botTab.classList.remove(oppositeStatusClass);
botTab.classList.add(currentStatusClass);
}
}
for (const subData of data.subreddits) {
const subredditTab = document.querySelector(`[data-bot="${name}"] [data-subreddit="${subData.name}"] .subredditTabStatus`);
if(subredditTab !== null) {
const currentSubIndicatorClass = `bg-${subData.indicator}-400`;
const nonSubIndicatorClasses = subIndicators.filter(x => x !== subData.indicator).map(x => `bg-${x}-400`);
if(!subredditTab.classList.contains(currentSubIndicatorClass)) {
for(const nonIndicator of nonSubIndicatorClasses) {
subredditTab.classList.remove(nonIndicator);
}
subredditTab.classList.add(currentSubIndicatorClass);
}
}
}
}
});
socket.on('liveStats', (resp) => {
let el;
let isAll = resp.system !== undefined;
let isAll = resp.name.toLowerCase() === 'all';
if(isAll) {
// got all
el = document.querySelector(`[data-subreddit="All"][data-bot="${resp.bot}"].sub`);
@@ -1096,17 +1131,87 @@
el = document.querySelector(`[data-subreddit="${resp.name}"].sub`);
}
if(isAll) {
if(resp.system.running && el.classList.contains('offline')) {
el.classList.remove('offline');
} else if(!resp.system.running && !el.classList.contains('offline')) {
el.classList.add('offline');
}
el.querySelector('.runningActivities').innerHTML = resp.runningActivities;
el.querySelector('.queuedActivities').innerHTML = resp.queuedActivities;
el.querySelector('.delayedItemsCount').innerHTML = resp.delayedItems.length;
el.querySelector('.delayedItemsList').innerHTML = 'No delayed Items!';
if(resp.delayedItems.length > 0) {
el.querySelector('.delayedItemsList').innerHTML = '';
const now = dayjs();
const sorted = resp.delayedItems.map(x => ({...x, dispatchAt: dayjs.unix(x.queuedAt + (x.durationMilli/1000))}));
sorted.sort((a, b) => {
return a.dispatchAt.isSameOrAfter(b.dispatchAt) ? 1 : -1
});
const delayedItemDivs = sorted.map(x => {
const diffUntilNow = x.dispatchAt.diff(now)
const durationUntilNow = dayjs.duration(diffUntilNow);
const cancelLink = `<a href="#" data-id="${x.id}" data-subreddit="${x.subreddit}" class="delayCancel">CANCEL</a>`;
return `<div>A <a href="https://reddit.com${x.permalink}">${x.submissionId !== undefined ? 'Comment' : 'Submssion'}</a>${isAll ? ` in <a href="https://reddit.com${x.subreddit}">${x.subreddit}</a> ` : ''} by <a href="https://reddit.com/u/${x.author}">${x.author}</a> queued by ${x.source} at ${dayjs.unix(x.queuedAt).format('HH:mm:ss z')} for ${x.duration} (dispatches in ${durationUntilNow.humanize()}) -- ${cancelLink}</div>`;
});
el.querySelector('.delayedItemsList').insertAdjacentHTML('afterbegin', delayedItemDivs.join(''));
el.querySelectorAll('.delayedItemsList .delayCancel').forEach(elm => {
elm.addEventListener('click', e => {
e.preventDefault();
const id = e.target.dataset.id;
const subreddit = e.target.dataset.subreddit;
fetch(`/api/delayed?instance=<%= instanceId %>&bot=${resp.bot}&subreddit=${subreddit}&id=${id}`, {
method: 'DELETE'
}).then((resp) => {
if (!resp.ok) {
console.error('Response was not OK from delay cancel');
} else {
console.log('Removed ok');
}
});
});
});
}
el.querySelector('.allStats .eventsCount').innerHTML = resp.stats.historical.allTime.eventsCheckedTotal;
el.querySelector('.allStats .checksCount').innerHTML = resp.stats.historical.allTime.checksTriggeredTotal;
el.querySelector('.allStats .rulesCount').innerHTML = resp.stats.historical.allTime.rulesTriggeredTotal;
el.querySelector('.allStats .actionsCount').innerHTML = resp.stats.historical.allTime.actionsRunTotal;
if(isAll) {
for(const elm of ['apiAvg','apiLimit','apiDepletion','nextHeartbeat', 'nextHeartbeatHuman', 'limitReset', 'limitResetHuman', 'nannyMode', 'startedAtHuman']) {
el.querySelector(`#${elm}`).innerHTML = resp[elm];
}
el.querySelector(`.botStatus`).innerHTML = resp.system.running ? 'ONLINE' : 'OFFLINE';
} else {
if(el.querySelector('.modPermissionsCount').innerHTML != resp.permissions.length) {
el.querySelector('.modPermissionsCount').innerHTML = resp.permissions.length;
el.querySelector('.modPermissionsList').innerHTML = '';
el.querySelector('.modPermissionsList').insertAdjacentHTML('afterbegin', resp.permissions.map(x => `<li class="font-mono">${x}</li>`).join(''));
}
el.querySelector('.reloadStats .eventsCount').innerHTML = resp.stats.historical.lastReload.eventsCheckedTotal;
el.querySelector('.reloadStats .checksCount').innerHTML = resp.stats.historical.lastReload.checksTriggeredTotal;
el.querySelector('.reloadStats .rulesCount').innerHTML = resp.stats.historical.lastReload.rulesTriggeredTotal;
el.querySelector('.reloadStats .actionsCount').innerHTML = resp.stats.historical.lastReload.actionsRunTotal;
for(const elm of ['botState', 'queueState', 'eventsState']) {
const state = resp[elm];
el.querySelector(`.${elm}`).innerHTML = `${state.state}${state.causedBy === 'system' ? '' : ' (user)'}`;
}
for(const elm of ['startedAt', 'startedAtHuman', 'wikiLastCheck', 'wikiLastCheckHuman', 'wikiRevision', 'wikiRevisionHuman', 'validConfig', 'delayBy']) {
el.querySelector(`.${elm}`).innerHTML = resp[elm];
}
el.querySelector(`.commentCheckCount`).innerHTML = resp.checks.comments;
el.querySelector(`.submissionCheckCount`).innerHTML = resp.checks.submissions;
const newInner = resp.pollingInfo.map(x => `<li>${x}</li>`).join('');
if(el.querySelector(`.pollingInfo`).innerHTML !== newInner) {
el.querySelector(`.pollingInfo`).innerHTML = newInner;
}
}
console.log(resp);
//console.log(resp);
});
});

View File

@@ -154,7 +154,7 @@ const program = new Command();
await b.buildManagers([sub]);
if(b.subManagers.length > 0) {
const manager = b.subManagers[0];
await manager.handleActivity(activity, {checkNames: checks});
await manager.handleActivity(activity, {checkNames: checks, source: 'user'});
break;
}
}
@@ -192,7 +192,7 @@ const program = new Command();
for (const a of activities.reverse()) {
manager.firehose.push({
activity: a,
options: {checkNames: checks}
options: {checkNames: checks, source: 'user'}
});
}
}

View File

@@ -10,12 +10,12 @@ import {inflateSync, deflateSync} from "zlib";
import pixelmatch from 'pixelmatch';
import os from 'os';
import {
ActionResult,
ActionResult, ActivitySource,
ActivityWindowCriteria,
ActivityWindowType,
CacheOptions,
CacheProvider,
CheckSummary,
CheckSummary, CommentState,
DurationComparison,
DurationVal,
FilterCriteriaDefaults,
@@ -27,7 +27,7 @@ import {
HistoricalStatsDisplay,
ImageComparisonResult,
//ImageData,
ImageDetection,
ImageDetection, ItemCritPropHelper,
//ImageDownloadOptions,
LogInfo,
NamedGroup,
@@ -37,14 +37,14 @@ import {
RedditEntityType,
RegExResult,
RepostItem,
RepostItemResult,
RepostItemResult, RequiredItemCrit,
ResourceStats,
RunResult,
SearchAndReplaceRegExp,
StringComparisonOptions,
StringOperator,
StrongSubredditState, SubmissionState,
SubredditState,
SubredditState, TypedActivityState,
TypedActivityStates
} from "./Common/interfaces";
import { Document as YamlDocument } from 'yaml'
@@ -2275,3 +2275,62 @@ export const getUserAgent = (val: string, fragment?: string) => {
export const replaceApplicationIdentifier = (val: string, fragment?: string) => {
return val.replace('{VERSION}', `v${VERSION}`).replace('{FRAG}', (fragment !== undefined ? `-${fragment}` : ''));
}
export const parseDurationValToDuration = (val: DurationVal): Duration => {
let duration: Duration;
if (typeof val === 'object') {
duration = dayjs.duration(val);
if (!dayjs.isDuration(duration)) {
throw new Error('window value given was not a well-formed Duration object');
}
} else {
try {
duration = parseDuration(val);
} catch (e) {
if (e instanceof InvalidRegexError) {
throw new Error(`duration value of '${val}' could not be parsed as a valid ISO8601 duration or DayJS duration shorthand (see Schema)`);
}
throw e;
}
}
return duration;
}
export const generateItemFilterHelpers = (stateCriteria: TypedActivityState): [ItemCritPropHelper, RequiredItemCrit] => {
const definedStateCriteria = (removeUndefinedKeys(stateCriteria) as RequiredItemCrit);
if(definedStateCriteria === undefined) {
return [{}, {} as RequiredItemCrit];
}
const propResultsMap = Object.entries(definedStateCriteria).reduce((acc: ItemCritPropHelper, [k, v]) => {
const key = (k as keyof (SubmissionState & CommentState));
acc[key] = {
property: key,
behavior: 'include',
};
return acc;
}, {});
return [propResultsMap, definedStateCriteria];
}
export const isCommentState = (state: TypedActivityState): state is CommentState => {
return 'op' in state || 'depth' in state || 'submissionState' in state;
}
const DISPATCH_REGEX: RegExp = /^dispatch:/i;
const POLL_REGEX: RegExp = /^poll:/i;
export const asActivitySource = (val: string): val is ActivitySource => {
if(['dispatch','poll','user'].some(x => x === val)) {
return true;
}
return DISPATCH_REGEX.test(val) || POLL_REGEX.test(val);
}
export const strToActivitySource = (val: string): ActivitySource => {
const cleanStr = val.trim();
if (asActivitySource(cleanStr)) {
return cleanStr;
}
throw new SimpleError(`'${cleanStr}' is not a valid ActivitySource. Must be one of: dispatch, dispatch:[identifier], poll, poll:[identifier], user`);
}

130
tests/snoowrapUtils.test.ts Normal file
View File

@@ -0,0 +1,130 @@
import {describe, it} from 'mocha';
import {assert} from 'chai';
import {mock, spy, when, instance} from 'ts-mockito';
import Snoowrap from "snoowrap";
import {Submission, Comment} from "snoowrap/dist/objects";
import {activityIsDeleted, activityIsFiltered, activityIsRemoved} from "../src/Utils/SnoowrapUtils";
const mockSnoowrap = new Snoowrap({userAgent: 'test', accessToken: 'test'});
describe('Activity state recognition', function () {
describe('activity is removed', function () {
describe('when bot is a moderator', function () {
it('submission not removed when filtered by automod', function () {
assert.isFalse(activityIsRemoved(new Submission({
can_mod_post: true,
banned_at_utc: 12345,
removed_by_category: 'automod_filtered'
}, mockSnoowrap, true)));
})
it('submission is removed when not filtered by automod', function () {
assert.isTrue(activityIsRemoved(new Submission({
can_mod_post: true,
banned_at_utc: 12345,
removed_by_category: 'mod'
}, mockSnoowrap, true)));
})
it('comment is removed', function () {
assert.isTrue(activityIsRemoved(new Comment({
can_mod_post: true,
banned_at_utc: 12345,
removed: true,
replies: ''
}, mockSnoowrap, true)));
})
})
describe('when bot is not a moderator', function () {
it('submission is deleted by moderator', function () {
assert.isTrue(activityIsRemoved(new Submission({
can_mod_post: false,
removed_by_category: 'moderator'
}, mockSnoowrap, true)));
})
it('submission is deleted by user or other', function () {
assert.isTrue(activityIsRemoved(new Submission({
can_mod_post: false,
removed_by_category: 'deleted'
}, mockSnoowrap, true)));
})
it('comment body is removed', function () {
assert.isTrue(activityIsRemoved(new Comment({
can_mod_post: false,
body: '[removed]',
replies: ''
}, mockSnoowrap, true)));
})
})
})
describe('activity is filtered', function() {
it('not filtered when user is not a moderator', function() {
assert.isFalse(activityIsFiltered(new Submission({
can_mod_post: false,
banned_at_utc: 12345,
removed_by_category: 'mod'
}, mockSnoowrap, true)));
})
it('submission is filtered', function () {
assert.isTrue(activityIsFiltered(new Submission({
can_mod_post: true,
banned_at_utc: 12345,
removed_by_category: 'automod_filtered'
}, mockSnoowrap, true)));
})
it('comment is filtered', function () {
assert.isTrue(activityIsFiltered(new Comment({
can_mod_post: true,
banned_at_utc: 12345,
removed: false,
replies: ''
}, mockSnoowrap, true)));
})
})
describe('activity is deleted', function() {
it('submission is deleted', function () {
assert.isTrue(activityIsDeleted(new Submission({
can_mod_post: true,
banned_at_utc: 12345,
removed_by_category: 'deleted'
}, mockSnoowrap, true)));
})
it('comment is deleted', function () {
assert.isTrue(activityIsDeleted(new Comment({
can_mod_post: true,
banned_at_utc: 12345,
removed: false,
replies: '',
author: {
name: '[deleted]'
}
}, mockSnoowrap, true)));
})
})
})

198
tests/utils.test.ts Normal file
View File

@@ -0,0 +1,198 @@
import {describe, it} from 'mocha';
import {assert} from 'chai';
import {
COMMENT_URL_ID,
parseDuration,
parseDurationComparison,
parseGenericValueComparison,
parseGenericValueOrPercentComparison, parseLinkIdentifier,
parseRedditEntity, removeUndefinedKeys, SUBMISSION_URL_ID
} from "../src/util";
import dayjs from "dayjs";
import dduration, {DurationUnitType} from 'dayjs/plugin/duration.js';
describe('Non-temporal Comparison Operations', function () {
it('should throw if no operator sign', function () {
const shouldThrow = () => parseGenericValueComparison('just 3');
assert.throws(shouldThrow)
});
it('should parse greater-than with a numeric value', function () {
const res = parseGenericValueComparison('> 3');
assert.equal(res.operator, '>')
assert.equal(res.value, 3);
});
it('should parse greater-than-or-equal-to with a numeric value', function () {
const res = parseGenericValueComparison('>= 3');
assert.equal(res.operator, '>=')
assert.equal(res.value, 3)
})
it('should parse less-than with a numeric value', function () {
const res = parseGenericValueComparison('< 3');
assert.equal(res.operator, '<')
assert.equal(res.value, 3)
})
it('should parse less-than-or-equal-to with a numeric value', function () {
const res = parseGenericValueComparison('<= 3');
assert.equal(res.operator, '<=')
assert.equal(res.value, 3)
})
it('should parse extra content', function () {
const res = parseGenericValueComparison('<= 3 foobars');
assert.equal(res.extra, ' foobars')
const noExtra = parseGenericValueComparison('<= 3');
assert.isUndefined(noExtra.extra)
})
it('should parse percentage', function () {
const withPercent = parseGenericValueOrPercentComparison('<= 3%');
assert.isTrue(withPercent.isPercent)
const withoutPercent = parseGenericValueOrPercentComparison('<= 3');
assert.isFalse(withoutPercent.isPercent)
})
});
describe('Parsing Temporal Values', function () {
before('Extend DayJS', function () {
dayjs.extend(dduration);
});
describe('Temporal Comparison Operations', function () {
it('should throw if no operator sign', function () {
assert.throws(() => parseDurationComparison('just 3'))
});
it('should throw if no units', function () {
assert.throws(() => parseDurationComparison('> 3'))
});
for (const unit of ['millisecond', 'milliseconds', 'second', 'seconds', 'minute', 'minutes', 'hour', 'hours', 'day', 'days', 'week', 'weeks', 'month', 'months', 'year', 'years']) {
it(`should accept ${unit} unit`, function () {
assert.doesNotThrow(() => parseDurationComparison(`> 3 ${unit}`))
});
}
it('should only accept units compatible with dayjs', function () {
assert.throws(() => parseDurationComparison('> 3 gigawatts'))
});
it('should parse greater-than with a duration', function () {
const res = parseDurationComparison('> 3 days');
assert.equal(res.operator, '>')
assert.isTrue(dayjs.isDuration(res.duration));
});
it('should parse greater-than-or-equal-to with a duration', function () {
const res = parseDurationComparison('>= 3 days');
assert.equal(res.operator, '>=')
assert.isTrue(dayjs.isDuration(res.duration));
})
it('should parse less-than with a duration', function () {
const res = parseDurationComparison('< 3 days');
assert.equal(res.operator, '<')
assert.isTrue(dayjs.isDuration(res.duration));
})
it('should parse less-than-or-equal-to with a duration', function () {
const res = parseDurationComparison('<= 3 days');
assert.equal(res.operator, '<=')
assert.isTrue(dayjs.isDuration(res.duration));
})
})
describe('Parsing Text', function () {
for (const unit of ['millisecond', 'milliseconds', 'second', 'seconds', 'minute', 'minutes', 'hour', 'hours', 'day', 'days', 'week', 'weeks', 'month', 'months', 'year', 'years']) {
it(`should accept ${unit} unit for duration`, function () {
assert.equal(parseDuration(`1 ${unit}`).asMilliseconds(), dayjs.duration(1, unit as DurationUnitType).asMilliseconds());
});
}
it('should accept ISO8601 durations', function () {
assert.equal(parseDuration('P23DT23H').asSeconds(), dayjs.duration({
days: 23,
hours: 23
}).asSeconds());
assert.equal(parseDuration('P3Y6M4DT12H30M5S').asSeconds(), dayjs.duration({
years: 3,
months: 6,
days: 4,
hours: 12,
minutes: 30,
seconds: 5
}).asSeconds());
});
})
});
describe('Parsing Reddit Entity strings', function () {
it('should recognize entity name regardless of prefix', function () {
for(const text of ['/r/anEntity', 'r/anEntity', '/u/anEntity', 'u/anEntity']) {
assert.equal(parseRedditEntity(text).name, 'anEntity');
}
})
it('should distinguish between subreddit and user prefixes', function () {
assert.equal(parseRedditEntity('r/mySubreddit').type, 'subreddit');
assert.equal(parseRedditEntity('u/aUser').type, 'user');
})
it('should recognize user based on u_ prefix', function () {
assert.equal(parseRedditEntity(' u_aUser ').type, 'user');
})
it('should handle whitespace', function () {
assert.equal(parseRedditEntity(' /r/mySubreddit ').name, 'mySubreddit');
})
it('should handle dashes in the entity name', function () {
assert.equal(parseRedditEntity(' /u/a-user ').name, 'a-user');
})
})
describe('Config Parsing', function () {
describe('Deep pruning of undefined keys on config objects', function () {
it('removes undefined keys from objects', function () {
const obj = {
keyA: 'foo',
keyB: 'bar',
keyC: undefined
};
assert.deepEqual({keyA: 'foo', keyB: 'bar'}, removeUndefinedKeys(obj))
})
it('returns undefined if object has no keys', function () {
const obj = {
keyA: undefined,
keyB: undefined,
keyC: undefined
};
assert.isUndefined(removeUndefinedKeys(obj))
})
it('ignores arrays', function () {
const obj = {
keyA: undefined,
keyB: 'bar',
keyC: ['foo', 'bar']
};
assert.deepEqual({keyB: 'bar', keyC: ['foo', 'bar']}, removeUndefinedKeys(obj))
})
})
})
describe('Link Recognition', function () {
describe('Parsing Reddit Permalinks', function () {
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
it('should recognize the comment id from a comment permalink', function () {
assert.equal(commentReg('https://www.reddit.com/r/pics/comments/92dd8/comment/c0b6xx0'), 'c0b6xx0');
})
it('should recognize the submission id from a comment permalink', function () {
assert.equal(submissionReg('https://www.reddit.com/r/pics/comments/92dd8/comment/c0b6xx0'), '92dd8');
})
it('should recognize the submission id from a submission permalink', function () {
assert.equal(submissionReg('https://www.reddit.com/r/pics/comments/92dd8/test_post_please_ignore/'), '92dd8');
})
// it('should recognize submission id from reddit shortlink')
// https://redd.it/92dd8
})
})

View File

@@ -9,18 +9,13 @@
"./src/Common/typings"
]
},
// "compilerOptions": {
// "module": "es6",
// "moduleResolution": "node",
// "target": "es6",
// "sourceMap": true,
// "allowSyntheticDefaultImports": true
// },
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": [
"node_modules"
"node_modules",
"coverage",
"tests/*.ts"
]
}