mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 07:57:57 -05:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e58a0f8f21 | ||
|
|
c46fe6f128 | ||
|
|
074c3c7340 | ||
|
|
8cd8374bbe | ||
|
|
aa0541f09b | ||
|
|
eee166467d | ||
|
|
95b0e529e2 | ||
|
|
45be87a72a | ||
|
|
d632364c7d | ||
|
|
9e660214eb | ||
|
|
14340b3a65 | ||
|
|
b07402628e | ||
|
|
035283a596 | ||
|
|
cc46f00a22 | ||
|
|
27263928cd | ||
|
|
0f122466ad | ||
|
|
32cdb29515 | ||
|
|
fe311ced32 | ||
|
|
e41bea7e6b | ||
|
|
9d169cebf3 | ||
|
|
ff3e704cdf | ||
|
|
caaeb2eefb | ||
|
|
8991797d35 | ||
|
|
aa95c26b2a | ||
|
|
11cc90e2d5 | ||
|
|
d11e511f67 | ||
|
|
a3708ca279 | ||
|
|
14d0417a25 | ||
|
|
c4adf4f495 | ||
|
|
95d146a504 | ||
|
|
ccc8a0dab5 | ||
|
|
1f3d0b50a7 | ||
|
|
d8d409ae6b |
@@ -6,3 +6,4 @@ src/logs
|
||||
.github
|
||||
/docs/
|
||||
/node_modules/
|
||||
coverage
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -381,6 +381,8 @@ dist
|
||||
.pnp.*
|
||||
|
||||
**/src/**/*.js
|
||||
**/tests/**/*.js
|
||||
**/tests/**/*.map
|
||||
!src/Web/assets/public/yaml/*
|
||||
**/src/**/*.map
|
||||
/**/*.sqlite
|
||||
|
||||
3
.idea/redditcontextbot.iml
generated
3
.idea/redditcontextbot.iml
generated
@@ -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
4
.mocharc.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"require": ["./register.js", "source-map-support/register"],
|
||||
"reporter": "dot"
|
||||
}
|
||||
24
.nycrc.json
Normal file
24
.nycrc.json
Normal 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
4191
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -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
21
register.js
Normal 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'
|
||||
});
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
135
src/Action/CancelDispatchAction.ts
Normal file
135
src/Action/CancelDispatchAction.ts
Normal 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'
|
||||
}
|
||||
140
src/Action/DispatchAction.ts
Normal file
140
src/Action/DispatchAction.ts
Normal 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'
|
||||
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`})]);
|
||||
|
||||
@@ -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 [{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -57,6 +57,7 @@ const sub: SubredditDataResponse = {
|
||||
queueState: runningState,
|
||||
queuedActivities: 0,
|
||||
runningActivities: 0,
|
||||
delayedItems: [],
|
||||
softLimit: 0,
|
||||
startedAt: "-",
|
||||
startedAtHuman: "-",
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface SubredditDataResponse {
|
||||
indicator: string
|
||||
queuedActivities: number
|
||||
runningActivities: number
|
||||
delayedItems: any[]
|
||||
maxWorkers: number
|
||||
subMaxWorkers: number
|
||||
globalMaxWorkers: number
|
||||
|
||||
@@ -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
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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'}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
69
src/util.ts
69
src/util.ts
@@ -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
130
tests/snoowrapUtils.test.ts
Normal 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
198
tests/utils.test.ts
Normal 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
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user