mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 07:57:57 -05:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3292d011fa | ||
|
|
d7cab4092d | ||
|
|
0370e592f9 | ||
|
|
116d06733a | ||
|
|
22a8a694a7 | ||
|
|
2ed24eee11 | ||
|
|
8822d8520a | ||
|
|
9832292a5b | ||
|
|
7a86c722fa | ||
|
|
2ca4043c02 | ||
|
|
4da8a0b353 | ||
|
|
492ff78b13 | ||
|
|
64a0b0890d | ||
|
|
546daddd49 | ||
|
|
f91d81029f | ||
|
|
68ee1718e0 | ||
|
|
c0d19ede39 | ||
|
|
bb05d64428 | ||
|
|
1977c7317f | ||
|
|
6f784d5aa2 | ||
|
|
4b5c9b82e4 | ||
|
|
0315ad23ae | ||
|
|
da70753f42 | ||
|
|
554d7dd86e | ||
|
|
29c3924ab7 | ||
|
|
5551f2c63f |
@@ -39,10 +39,13 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
|
||||
* [Message](#message)
|
||||
* [Remove](#remove)
|
||||
* [Report](#report)
|
||||
* [UserNote](#usernote)
|
||||
* [Toolbox UserNote](#usernote)
|
||||
* [Mod Note](#mod-note)
|
||||
* [Filters](#filters)
|
||||
* [Filter Types](#filter-types)
|
||||
* [Author Filter](#author-filter)
|
||||
* [Mod Notes/Actions](#mod-actionsnotes-filter)
|
||||
* [Toolbox UserNotes](#toolbox-usernotes-filter)
|
||||
* [Item Filter](#item-filter)
|
||||
* [Subreddit Filter](#subreddit-filter)
|
||||
* [Named Filters](#named-filters)
|
||||
@@ -651,6 +654,28 @@ actions:
|
||||
allowDuplicate: boolean # if false then the usernote will not be added if the same note appears for this activity
|
||||
```
|
||||
|
||||
### Mod Note
|
||||
|
||||
Add a [Mod Note](https://www.reddit.com/r/modnews/comments/t8vafc/announcing_mod_notes/) for the Author of the Activity.
|
||||
|
||||
* `type` must be one of the [valid note labels](https://www.reddit.com/dev/api#POST_api_mod_notes):
|
||||
* BOT_BAN
|
||||
* PERMA_BAN
|
||||
* BAN
|
||||
* ABUSE_WARNING
|
||||
* SPAM_WARNING
|
||||
* SPAM_WATCH
|
||||
* SOLID_CONTRIBUTOR
|
||||
* HELPFUL_USER
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: modnote
|
||||
type: SPAM_WATCH
|
||||
content: 'a note only mods can see message' # optional
|
||||
referenceActivity: boolean # if true the Note will be linked to the Activity being processed
|
||||
```
|
||||
|
||||
# Filters
|
||||
|
||||
**Filters** are an additional channel for determining if an Event should be processed by ContextMod. They differ from **Rules** in several key ways:
|
||||
@@ -732,6 +757,14 @@ There are two types of Filter. Both types have the same "shape" in the configura
|
||||
|
||||
Test the Author of an Activity. See [Schema documentation](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json) for all possible Author Criteria
|
||||
|
||||
#### Mod Actions/Notes Filter
|
||||
|
||||
See [Mod Actions/Notes](/docs/subreddit/components/modActions/README.md#mod-action-filter) documentation.
|
||||
|
||||
#### Toolbox UserNotes Filter
|
||||
|
||||
See [UserNotes](/docs/subreddit/components/userNotes/README.md) documentation
|
||||
|
||||
### Item Filter
|
||||
|
||||
Test for properties of an Activity:
|
||||
|
||||
152
docs/subreddit/components/modActions/README.md
Normal file
152
docs/subreddit/components/modActions/README.md
Normal file
@@ -0,0 +1,152 @@
|
||||
# Table of Contents
|
||||
|
||||
* [Overview](#overview)
|
||||
* [Mod Note Action](#mod-note-action)
|
||||
* [Mod Action Filter](#mod-action-filter)
|
||||
* [API Usage](#api-usage)
|
||||
* [When To Use?](#when-to-use)
|
||||
* [Examples](#examples)
|
||||
|
||||
# Overview
|
||||
|
||||
[Mod Notes](https://www.reddit.com/r/modnews/comments/t8vafc/announcing_mod_notes/) is a feature for New Reddit that allow moderators to add short, categorizable notes to Users of their subreddit, optionally associating te note with a submission/comment the User made. They are inspired by [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) which are also [supported by ContextMod.](/docs/subreddit/components/userNotes) Reddit's **Mod Notes** also combine [Moderation Log](https://mods.reddithelp.com/hc/en-us/articles/360022402312-Moderation-Log) actions (**Mod Actions**) for the selected User alongside moderator notes, enabling a full "overview" of moderator interactions with a User in their subreddit.
|
||||
|
||||
ContextMod supports adding **Mod Notes** to an Author using an [Action](/docs/subreddit/components/README.md#mod-note) and using **Mod Actions/Mod Notes** as a criteria in an [Author Filter](/docs/subreddit/components/README.md#author-filter)
|
||||
|
||||
# Mod Note Action
|
||||
|
||||
[**Schema Reference**](https://json-schema.app/view/%23%2Fdefinitions%2FModNoteActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json)
|
||||
|
||||
* `type` must be one of the [valid note labels](https://www.reddit.com/dev/api#POST_api_mod_notes):
|
||||
* BOT_BAN
|
||||
* PERMA_BAN
|
||||
* BAN
|
||||
* ABUSE_WARNING
|
||||
* SPAM_WARNING
|
||||
* SPAM_WATCH
|
||||
* SOLID_CONTRIBUTOR
|
||||
* HELPFUL_USER
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: modnote
|
||||
type: SPAM_WATCH
|
||||
content: 'a note only mods can see message' # optional
|
||||
referenceActivity: boolean # if true the Note will be linked to the Activity being processed
|
||||
```
|
||||
|
||||
# Mod Action Filter
|
||||
|
||||
ContextMod can use **Mod Actions** (from moderation log) and **Mod Notes** in an [Author Filter](/docs/subreddit/components/README.md#author-filter).
|
||||
|
||||
## API Usage
|
||||
|
||||
Notes/Actions are **not** included in the data Reddit returns for either an Author or an Activity. This means that, in most cases, ContextMod is required to make **one additional API call to Reddit during Activity processing** if Notes/Actions as used as part of an **Author Filter**.
|
||||
|
||||
The impact of this additional call is greatest when the Author Filter is used as part of a **Comment Check** or running for **every Activity** such as part of a Run. Take this example:
|
||||
|
||||
No Mod Action filtering
|
||||
|
||||
* CM makes 1 api call to return new comments, find 10 new comments across 6 users
|
||||
* Processing each comment, with no other filters, requires 0 additional calls
|
||||
* At the end of processing 10 comments, CM has used a total of 1 api call.
|
||||
|
||||
Mod Action Filtering Used
|
||||
|
||||
* CM makes 1 api call to return new comments, find 10 new comments across 6 users
|
||||
* Processing each comment, with a mod action filter, requires 1 additional api call per user
|
||||
* At the end of processing 10 comments, CM has used a total of **7 api calls**
|
||||
|
||||
### When To Use?
|
||||
|
||||
In general,**do not** use Mod Actions in a Filter if:
|
||||
|
||||
* The filter is on a [**Comment** Check](/docs/subreddit/components/README.md#checks) and your subreddit has a high volume of Comments
|
||||
* The filter is on a [Run](/docs/subreddit/components/README.md#runs) and your subreddit has a high volume of Activities
|
||||
|
||||
If you need Mod Notes-like functionality for a high volume subreddit consider using [Toolbox UserNotes](/docs/subreddit/components/userNotes) instead.
|
||||
|
||||
In general, **do** use Mod Actions in a Filter if:
|
||||
|
||||
* The filter is on a [**Submission** Check](/docs/subreddit/components/README.md#checks)
|
||||
* The filter is part of an [Author **Rule**](/docs/subreddit/components/README.md#author) that is processed as **late as possible in the rule order for a Check**
|
||||
* Your subreddit has a low volume of Activities (less than 100 combined submissions/comments in a 10 minute period, for example)
|
||||
* The filter is on an Action
|
||||
|
||||
## Usage and Examples
|
||||
|
||||
Filter by Mod Actions/Notes on an Author Filter are done using the `modActions` property:
|
||||
|
||||
```yaml
|
||||
age: '> 1 month'
|
||||
# ...
|
||||
modActions:
|
||||
- ...
|
||||
```
|
||||
|
||||
There two valid shapes for the Mod Action criteria: [ModLogCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FModLogCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json) and [ModNoteCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FModNoteCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json).
|
||||
|
||||
### ModLogCriteria
|
||||
|
||||
Used for filtering by **Moderation Log** actions *and/or general notes*.
|
||||
|
||||
* `activityType` -- Optional. If Mod Action is associated with an activity specify it here. A list or one of:
|
||||
* `submission`
|
||||
* `comment`
|
||||
* `type` -- Optional. The type of Mod Log Action. A list or one of:
|
||||
* `INVITE`
|
||||
* `NOTE`
|
||||
* `REMOVAL`
|
||||
* `SPAM`
|
||||
* `APPROVAL`
|
||||
* `description` -- additional mod log details (string) to filter by -- not documented by reddit. Can be string or regex string-like `/.* test/i`
|
||||
* `details` -- additional mod log details (string) to filter by -- not documented by reddit. Can be string or regex string-like `/.* test/i`
|
||||
|
||||
```yaml
|
||||
activityType: submission
|
||||
type:
|
||||
- REMOVAL
|
||||
- SPAM
|
||||
search: total
|
||||
count: '> 3 in 1 week'
|
||||
```
|
||||
### ModNoteCriteria
|
||||
|
||||
Inherits `activityType` from ModLogCriteria. If either of the below properties in included on the criteria then any other ModLogCriteria-specific properties are **ignored**.
|
||||
|
||||
* `note` -- the contents of the note to match against. Can be one of or a list of strings/regex string-like `/.* test/i`
|
||||
* `noteType` -- If specified by the note, the note type (see [Mod Note Action](#mod-note-action) type). Can be one of or a list of strings/regex string-like `/.* test/i`
|
||||
|
||||
```yaml
|
||||
noteType: SOLID_CONTRIBUTOR
|
||||
search: total
|
||||
count: '> 3 in 1 week'
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
Author has more than 2 submission approvals in the last month
|
||||
|
||||
```yaml
|
||||
type: APPROVAL
|
||||
activityType: submission
|
||||
search: total
|
||||
count: '> 2 in 1 month'
|
||||
```
|
||||
|
||||
Author has at least 1 BAN note
|
||||
|
||||
```yaml
|
||||
noteType: BAN
|
||||
search: total
|
||||
count: '>= 1'
|
||||
```
|
||||
|
||||
Author has at least 3 notes which include the words "self" and "promotion" in the last month
|
||||
|
||||
```yaml
|
||||
note: '/self.*promo/i'
|
||||
activityType: submission
|
||||
search: total
|
||||
count: '>= 3 in 1 month'
|
||||
```
|
||||
@@ -17,6 +17,7 @@ import {DispatchAction, DispatchActionJson} from "./DispatchAction";
|
||||
import {CancelDispatchAction, CancelDispatchActionJson} from "./CancelDispatchAction";
|
||||
import ContributorAction, {ContributorActionJson} from "./ContributorAction";
|
||||
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {ModNoteAction, ModNoteActionJson} from "./ModNoteAction";
|
||||
|
||||
export function actionFactory
|
||||
(config: StructuredActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap, emitter: EventEmitter): Action {
|
||||
@@ -47,6 +48,8 @@ export function actionFactory
|
||||
return new CancelDispatchAction({...config as StructuredFilter<CancelDispatchActionJson>, logger, subredditName, resources, client, emitter})
|
||||
case 'contributor':
|
||||
return new ContributorAction({...config as StructuredFilter<ContributorActionJson>, logger, subredditName, resources, client, emitter})
|
||||
case 'modnote':
|
||||
return new ModNoteAction({...config as StructuredFilter<ModNoteActionJson>, logger, subredditName, resources, client, emitter})
|
||||
default:
|
||||
throw new Error('rule "kind" was not recognized.');
|
||||
}
|
||||
|
||||
108
src/Action/ModNoteAction.ts
Normal file
108
src/Action/ModNoteAction.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {ActionJson, ActionConfig, ActionOptions} from "./index";
|
||||
import Action from "./index";
|
||||
import {Comment} from "snoowrap";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {ActionProcessResult, RichContent} from "../Common/interfaces";
|
||||
import {toModNoteLabel} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes, ModUserNoteLabel} from "../Common/Infrastructure/Atomic";
|
||||
import {ModNote} from "../Subreddit/ModNotes/ModNote";
|
||||
|
||||
|
||||
export class ModNoteAction extends Action {
|
||||
content: string;
|
||||
type?: string;
|
||||
allowDuplicate: boolean;
|
||||
referenceActivity: boolean
|
||||
|
||||
constructor(options: ModNoteActionOptions) {
|
||||
super(options);
|
||||
const {type, content = '', allowDuplicate = false, referenceActivity = true} = options;
|
||||
this.type = type;
|
||||
this.content = content;
|
||||
this.allowDuplicate = allowDuplicate;
|
||||
this.referenceActivity = referenceActivity;
|
||||
}
|
||||
|
||||
getKind(): ActionTypes {
|
||||
return 'modnote';
|
||||
}
|
||||
|
||||
protected getSpecificPremise(): object {
|
||||
return {
|
||||
content: this.content,
|
||||
type: this.type,
|
||||
allowDuplicate: this.allowDuplicate,
|
||||
referenceActivity: this.referenceActivity,
|
||||
}
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
|
||||
const modLabel = this.type !== undefined ? toModNoteLabel(this.type) : undefined;
|
||||
|
||||
const content = await this.resources.getContent(this.content, item.subreddit);
|
||||
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
|
||||
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
|
||||
|
||||
// TODO see what changes are made for bulk fetch of notes before implementing this
|
||||
// https://www.reddit.com/r/redditdev/comments/t8w861/new_mod_notes_api/
|
||||
// if (!this.allowDuplicate) {
|
||||
// const notes = await this.resources.userNotes.getUserNotes(item.author);
|
||||
// let existingNote = notes.find((x) => x.link !== null && x.link.includes(item.id));
|
||||
// if(existingNote === undefined && notes.length > 0) {
|
||||
// const lastNote = notes[notes.length - 1];
|
||||
// // possibly notes don't have a reference link so check if last one has same text
|
||||
// if(lastNote.link === null && lastNote.text === renderedContent) {
|
||||
// existingNote = lastNote;
|
||||
// }
|
||||
// }
|
||||
// if (existingNote !== undefined && existingNote.noteType === this.type) {
|
||||
// this.logger.info(`Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`);
|
||||
// return {
|
||||
// dryRun,
|
||||
// success: false,
|
||||
// result: `Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
if (!dryRun) {
|
||||
await this.resources.addModNote({
|
||||
label: modLabel,
|
||||
note: renderedContent,
|
||||
activity: this.referenceActivity ? item : undefined,
|
||||
subreddit: this.resources.subreddit,
|
||||
user: item.author
|
||||
});
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
dryRun,
|
||||
result: `${modLabel !== undefined ? `(${modLabel})` : ''} ${renderedContent}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModNoteActionConfig extends ActionConfig, RichContent {
|
||||
/**
|
||||
* Add Note even if a Note already exists for this Activity
|
||||
* @examples [false]
|
||||
* @default false
|
||||
* */
|
||||
allowDuplicate?: boolean,
|
||||
type?: ModUserNoteLabel
|
||||
referenceActivity?: boolean
|
||||
}
|
||||
|
||||
export interface ModNoteActionOptions extends Omit<ModNoteActionConfig, 'authorIs' | 'itemIs'>, ActionOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Toolbox User Note to the Author of this Activity
|
||||
* */
|
||||
export interface ModNoteActionJson extends ModNoteActionConfig, ActionJson {
|
||||
kind: 'modnote'
|
||||
}
|
||||
@@ -434,7 +434,7 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
|
||||
checkSum.postBehavior = this.postFail.behavior;
|
||||
}
|
||||
|
||||
behaviorT = checkSum.triggered ? 'Trigger' : 'Fail';
|
||||
behaviorT = checkResult.triggered ? 'Trigger' : 'Fail';
|
||||
|
||||
switch (checkSum.postBehavior.toLowerCase()) {
|
||||
case 'next':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import YamlConfigDocument from "../YamlConfigDocument";
|
||||
import JsonConfigDocument from "../JsonConfigDocument";
|
||||
import {YAMLMap, YAMLSeq} from "yaml";
|
||||
import {YAMLMap, YAMLSeq, Pair, Scalar} from "yaml";
|
||||
import {BotInstanceJsonConfig, OperatorJsonConfig} from "../../interfaces";
|
||||
import {assign} from 'comment-json';
|
||||
|
||||
@@ -15,10 +15,12 @@ export class YamlOperatorConfigDocument extends YamlConfigDocument implements Op
|
||||
if (bots === undefined) {
|
||||
this.parsed.add({key: 'bots', value: [botData]});
|
||||
} else if (botData.name !== undefined) {
|
||||
// overwrite if we find an existing
|
||||
// granularly overwrite (merge) if we find an existing
|
||||
const existingIndex = bots.items.findIndex(x => (x as YAMLMap).get('name') === botData.name);
|
||||
if (existingIndex !== -1) {
|
||||
this.parsed.setIn(['bots', existingIndex], botData);
|
||||
const botObj = this.parsed.getIn(['bots', existingIndex]) as YAMLMap;
|
||||
const mergedVal = mergeObjectToYaml(botData, botObj);
|
||||
this.parsed.setIn(['bots', existingIndex], mergedVal);
|
||||
} else {
|
||||
this.parsed.addIn(['bots'], botData);
|
||||
}
|
||||
@@ -32,6 +34,24 @@ export class YamlOperatorConfigDocument extends YamlConfigDocument implements Op
|
||||
}
|
||||
}
|
||||
|
||||
export const mergeObjectToYaml = (source: object, target: YAMLMap) => {
|
||||
for (const [k, v] of Object.entries(source)) {
|
||||
if (target.has(k)) {
|
||||
const targetProp = target.get(k);
|
||||
if (targetProp instanceof YAMLMap && typeof v === 'object') {
|
||||
const merged = mergeObjectToYaml(v, targetProp);
|
||||
target.set(k, merged)
|
||||
} else {
|
||||
// since target prop and value are not both objects don't bother merging, just overwrite (primitive or array)
|
||||
target.set(k, v);
|
||||
}
|
||||
} else {
|
||||
target.add({key: k, value: v});
|
||||
}
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export class JsonOperatorConfigDocument extends JsonConfigDocument implements OperatorConfigDocumentInterface {
|
||||
addBot(botData: BotInstanceJsonConfig) {
|
||||
if (this.parsed.bots === undefined) {
|
||||
|
||||
@@ -185,7 +185,8 @@ export type ActionTypes =
|
||||
| 'userflair'
|
||||
| 'dispatch'
|
||||
| 'cancelDispatch'
|
||||
| 'contributor';
|
||||
| 'contributor'
|
||||
| 'modnote';
|
||||
|
||||
/**
|
||||
* Test the calculated VADER sentiment (compound) score for an Activity using this comparison. Can be either a numerical or natural language
|
||||
@@ -229,3 +230,22 @@ export type ActionTypes =
|
||||
* @examples ["is negative", "> 0.2"]
|
||||
* */
|
||||
export type VaderSentimentComparison = string;
|
||||
|
||||
export type ModUserNoteLabel =
|
||||
'BOT_BAN'
|
||||
| 'PERMA_BAN'
|
||||
| 'BAN'
|
||||
| 'ABUSE_WARNING'
|
||||
| 'SPAM_WARNING'
|
||||
| 'SPAM_WATCH'
|
||||
| 'SOLID_CONTRIBUTOR'
|
||||
| 'HELPFUL_USER';
|
||||
|
||||
export const modUserNoteLabels = ['BOT_BAN', 'PERMA_BAN', 'BAN', 'ABUSE_WARNING', 'SPAM_WARNING', 'SPAM_WATCH', 'SOLID_CONTRIBUTOR', 'HELPFUL_USER'];
|
||||
|
||||
export type ModActionType =
|
||||
'INVITE' |
|
||||
'NOTE' |
|
||||
'REMOVAL' |
|
||||
'SPAM' |
|
||||
'APPROVAL';
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {StringOperator} from "./Atomic";
|
||||
import {Duration} from "dayjs/plugin/duration";
|
||||
import InvalidRegexError from "../../Utils/InvalidRegexError";
|
||||
import dayjs, {Dayjs, OpUnitType} from "dayjs";
|
||||
import {SimpleError} from "../../Utils/Errors";
|
||||
import { parseDuration } from "../../util";
|
||||
|
||||
export interface DurationComparison {
|
||||
operator: StringOperator,
|
||||
@@ -13,6 +16,7 @@ export interface GenericComparison extends HasDisplayText {
|
||||
isPercent: boolean,
|
||||
extra?: string,
|
||||
displayText: string,
|
||||
duration?: Duration
|
||||
}
|
||||
|
||||
export interface HasDisplayText {
|
||||
@@ -30,35 +34,108 @@ export const asGenericComparison = (val: any): val is GenericComparison => {
|
||||
|
||||
export const GENERIC_VALUE_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>-?\d?\.?\d+)(?<extra>\s+.*)*$/
|
||||
export const GENERIC_VALUE_COMPARISON_URL = 'https://regexr.com/60dq4';
|
||||
export const parseGenericValueComparison = (val: string): GenericComparison => {
|
||||
const matches = val.match(GENERIC_VALUE_COMPARISON);
|
||||
export const parseGenericValueComparison = (val: string, options?: {
|
||||
requireDuration?: boolean,
|
||||
reg?: RegExp
|
||||
}): GenericComparison => {
|
||||
|
||||
const {
|
||||
requireDuration = false,
|
||||
reg = GENERIC_VALUE_COMPARISON,
|
||||
} = options || {};
|
||||
|
||||
const matches = val.match(reg);
|
||||
|
||||
if (matches === null) {
|
||||
throw new InvalidRegexError(GENERIC_VALUE_COMPARISON, val, GENERIC_VALUE_COMPARISON_URL)
|
||||
throw new InvalidRegexError(reg, val)
|
||||
}
|
||||
|
||||
const groups = matches.groups as any;
|
||||
|
||||
let duration: Duration | undefined;
|
||||
|
||||
if(typeof groups.extra === 'string' && groups.extra.trim() !== '') {
|
||||
try {
|
||||
duration = parseDuration(groups.extra, false);
|
||||
} catch (e) {
|
||||
// if it returns an invalid regex just means they didn't
|
||||
if (requireDuration || !(e instanceof InvalidRegexError)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
} else if(requireDuration) {
|
||||
throw new SimpleError(`Comparison must contain a duration value but none was found. Given: ${val}`);
|
||||
}
|
||||
|
||||
const displayParts = [`${groups.opStr} ${groups.value}`];
|
||||
const hasPercent = typeof groups.percent === 'string' && groups.percent.trim() !== '';
|
||||
if(hasPercent) {
|
||||
displayParts.push('%');
|
||||
}
|
||||
|
||||
return {
|
||||
operator: groups.opStr as StringOperator,
|
||||
value: Number.parseFloat(groups.value),
|
||||
isPercent: false,
|
||||
isPercent: hasPercent,
|
||||
extra: groups.extra,
|
||||
displayText: `${groups.opStr} ${groups.value}`
|
||||
displayText: displayParts.join(''),
|
||||
duration
|
||||
}
|
||||
}
|
||||
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%?)(?<extra>.*)$/
|
||||
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%)?(?<extra>.*)$/
|
||||
const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/60a16';
|
||||
export const parseGenericValueOrPercentComparison = (val: string): GenericComparison => {
|
||||
const matches = val.match(GENERIC_VALUE_PERCENT_COMPARISON);
|
||||
export const parseGenericValueOrPercentComparison = (val: string, options?: {requireDuration: boolean}): GenericComparison => {
|
||||
return parseGenericValueComparison(val, {...(options ?? {}), reg: GENERIC_VALUE_PERCENT_COMPARISON});
|
||||
}
|
||||
/**
|
||||
* Named groups: operator, time, unit
|
||||
* */
|
||||
const DURATION_COMPARISON_REGEX: RegExp = /^\s*(?<opStr>>|>=|<|<=)\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$/;
|
||||
const DURATION_COMPARISON_REGEX_URL = 'https://regexr.com/609n8';
|
||||
export const parseDurationComparison = (val: string): DurationComparison => {
|
||||
const matches = val.match(DURATION_COMPARISON_REGEX);
|
||||
if (matches === null) {
|
||||
throw new InvalidRegexError(GENERIC_VALUE_PERCENT_COMPARISON, val, GENERIC_VALUE_PERCENT_COMPARISON_URL)
|
||||
throw new InvalidRegexError(DURATION_COMPARISON_REGEX, val, DURATION_COMPARISON_REGEX_URL)
|
||||
}
|
||||
const groups = matches.groups as any;
|
||||
|
||||
const dur: Duration = dayjs.duration(groups.time, groups.unit);
|
||||
if (!dayjs.isDuration(dur)) {
|
||||
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
|
||||
}
|
||||
return {
|
||||
operator: groups.opStr as StringOperator,
|
||||
value: Number.parseFloat(groups.value),
|
||||
isPercent: groups.percent !== '',
|
||||
extra: groups.extra,
|
||||
displayText: `${groups.opStr} ${groups.value}${groups.percent === undefined || groups.percent === '' ? '' : '%'}`
|
||||
duration: dur
|
||||
}
|
||||
}
|
||||
export const dateComparisonTextOp = (val1: Dayjs, strOp: StringOperator, val2: Dayjs, granularity?: OpUnitType): boolean => {
|
||||
switch (strOp) {
|
||||
case '>':
|
||||
return val1.isBefore(val2, granularity);
|
||||
case '>=':
|
||||
return val1.isSameOrBefore(val2, granularity);
|
||||
case '<':
|
||||
return val1.isAfter(val2, granularity);
|
||||
case '<=':
|
||||
return val1.isSameOrAfter(val2, granularity);
|
||||
default:
|
||||
throw new Error(`${strOp} was not a recognized operator`);
|
||||
}
|
||||
}
|
||||
export const compareDurationValue = (comp: DurationComparison, date: Dayjs) => {
|
||||
const dateToCompare = dayjs().subtract(comp.duration.asSeconds(), 'seconds');
|
||||
return dateComparisonTextOp(date, comp.operator, dateToCompare);
|
||||
}
|
||||
export const comparisonTextOp = (val1: number, strOp: string, val2: number): boolean => {
|
||||
switch (strOp) {
|
||||
case '>':
|
||||
return val1 > val2;
|
||||
case '>=':
|
||||
return val1 >= val2;
|
||||
case '<':
|
||||
return val1 < val2;
|
||||
case '<=':
|
||||
return val1 <= val2;
|
||||
default:
|
||||
throw new Error(`${strOp} was not a recognized operator`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import {CompareValue, CompareValueOrPercent, DurationComparor, ModeratorNameCriteria, ModeratorNames} from "../Atomic";
|
||||
import {
|
||||
CompareValue,
|
||||
CompareValueOrPercent,
|
||||
DurationComparor,
|
||||
ModeratorNameCriteria,
|
||||
ModeratorNames, ModActionType,
|
||||
ModUserNoteLabel
|
||||
} from "../Atomic";
|
||||
import {ActivityType} from "../Reddit";
|
||||
import {GenericComparison, parseGenericValueComparison} from "../Comparisons";
|
||||
import {parseStringToRegexOrLiteralSearch} from "../../../util";
|
||||
|
||||
/**
|
||||
* Different attributes a `Subreddit` can be in. Only include a property if you want to check it.
|
||||
@@ -55,42 +65,40 @@ export const defaultStrongSubredditCriteriaOptions = {
|
||||
|
||||
export type FilterCriteriaDefaultBehavior = 'replace' | 'merge';
|
||||
|
||||
export interface UserNoteCriteria {
|
||||
/**
|
||||
* User Note type key to search for
|
||||
* @examples ["spamwarn"]
|
||||
* */
|
||||
type: string;
|
||||
export interface UserSubredditHistoryCriteria {
|
||||
/**
|
||||
* Number of occurrences of this type. Ignored if `search` is `current`
|
||||
*
|
||||
* A string containing a comparison operator and/or a value to compare number of occurrences against
|
||||
*
|
||||
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`
|
||||
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`
|
||||
*
|
||||
* If `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`
|
||||
*
|
||||
* @examples [">= 1"]
|
||||
* @default ">= 1"
|
||||
* @pattern ^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%?)\s*(?<extra>asc.*|desc.*)*$
|
||||
* @pattern ^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%?)\s*(?<duration>in\s+\d+\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\s*(?<extra>asc.*|desc.*)*$
|
||||
* */
|
||||
count?: string;
|
||||
|
||||
/**
|
||||
* How to test the notes for this Author:
|
||||
* How to test the Toolbox Notes or Mod Actions for this Author:
|
||||
*
|
||||
* ### current
|
||||
*
|
||||
* Only the most recent note is checked for `type`
|
||||
* Only the most recent note is checked for criteria
|
||||
*
|
||||
* ### total
|
||||
*
|
||||
* The `count` comparison of `type` must be found within all notes
|
||||
* `count` comparison of mod actions/notes must be found within all history
|
||||
*
|
||||
* * EX `count: > 3` => Must have more than 3 notes of `type`, total
|
||||
* * EX `count: <= 25%` => Must have 25% or less of notes of `type`, total
|
||||
* * EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week
|
||||
*
|
||||
* ### consecutive
|
||||
*
|
||||
* The `count` **number** of `type` notes must be found in a row.
|
||||
* The `count` **number** of mod actions/notes must be found in a row.
|
||||
*
|
||||
* You may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`
|
||||
*
|
||||
@@ -104,7 +112,126 @@ export interface UserNoteCriteria {
|
||||
search?: 'current' | 'consecutive' | 'total'
|
||||
}
|
||||
|
||||
export const authorCriteriaProperties = ['name', 'flairCssClass', 'flairText', 'flairTemplate', 'isMod', 'userNotes', 'age', 'linkKarma', 'commentKarma', 'totalKarma', 'verified', 'shadowBanned', 'description', 'isContributor'];
|
||||
export interface UserNoteCriteria extends UserSubredditHistoryCriteria {
|
||||
/**
|
||||
* User Note type key to search for
|
||||
* @examples ["spamwarn"]
|
||||
* */
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ModActionCriteria extends UserSubredditHistoryCriteria {
|
||||
type?: ModActionType | ModActionType[]
|
||||
activityType?: ActivityType | ActivityType[]
|
||||
}
|
||||
|
||||
export interface FullModActionCriteria extends Omit<ModActionCriteria, 'count'> {
|
||||
type?: ModActionType[]
|
||||
count?: GenericComparison
|
||||
activityType?: ActivityType[]
|
||||
}
|
||||
|
||||
export interface ModNoteCriteria extends ModActionCriteria {
|
||||
noteType?: ModUserNoteLabel | ModUserNoteLabel[]
|
||||
note?: string | string[]
|
||||
}
|
||||
|
||||
export interface FullModNoteCriteria extends FullModActionCriteria, Omit<ModNoteCriteria, 'note' | 'count' | 'type' | 'activityType'> {
|
||||
noteType?: ModUserNoteLabel[]
|
||||
note?: RegExp[]
|
||||
}
|
||||
|
||||
const arrayableModNoteProps = ['activityType','noteType','note'];
|
||||
|
||||
export const asModNoteCriteria = (val: any): val is ModNoteCriteria => {
|
||||
return val !== null && typeof val === 'object' && ('noteType' in val || 'note' in val);
|
||||
}
|
||||
|
||||
export const toFullModNoteCriteria = (val: ModNoteCriteria): FullModNoteCriteria => {
|
||||
|
||||
const result = Object.entries(val).reduce((acc: FullModNoteCriteria, curr) => {
|
||||
const [k,v] = curr;
|
||||
|
||||
if(v === undefined) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const rawVal = arrayableModNoteProps.includes(k) && !Array.isArray(v) ? [v] : v;
|
||||
|
||||
switch(k) {
|
||||
case 'search':
|
||||
acc.search = rawVal;
|
||||
break;
|
||||
case 'count':
|
||||
acc.count = parseGenericValueComparison(rawVal);
|
||||
break;
|
||||
case 'activityType':
|
||||
case 'noteType':
|
||||
acc[k] = rawVal;
|
||||
break;
|
||||
case 'note':
|
||||
acc[k] = rawVal.map((x: string) => parseStringToRegexOrLiteralSearch(x))
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
result.type = ['NOTE'];
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
export interface ModLogCriteria extends ModActionCriteria {
|
||||
action?: string | string[]
|
||||
details?: string | string[]
|
||||
description?: string | string[]
|
||||
}
|
||||
|
||||
export interface FullModLogCriteria extends FullModActionCriteria, Omit<ModLogCriteria, 'action' | 'details' | 'description' | 'count' | 'type' | 'activityType'> {
|
||||
action?: RegExp[]
|
||||
details?: RegExp[]
|
||||
description?: RegExp[]
|
||||
}
|
||||
|
||||
const arrayableModLogProps = ['type','activityType','action','description','details', 'type'];
|
||||
|
||||
export const asModLogCriteria = (val: any): val is ModLogCriteria => {
|
||||
return val !== null && typeof val === 'object' && !asModNoteCriteria(val) && ('action' in val || 'details' in val || 'description' in val || 'activityType' in val || 'search' in val || 'count' in val || 'type' in val);
|
||||
}
|
||||
|
||||
export const toFullModLogCriteria = (val: ModLogCriteria): FullModLogCriteria => {
|
||||
|
||||
return Object.entries(val).reduce((acc: FullModLogCriteria, curr) => {
|
||||
const [k,v] = curr;
|
||||
|
||||
if(v === undefined) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
const rawVal = arrayableModLogProps.includes(k) && !Array.isArray(v) ? [v] : v;
|
||||
|
||||
switch(k) {
|
||||
case 'search':
|
||||
acc.search = rawVal;
|
||||
break;
|
||||
case 'count':
|
||||
acc.count = parseGenericValueComparison(rawVal);
|
||||
break;
|
||||
case 'activityType':
|
||||
case 'type':
|
||||
acc[k as keyof FullModLogCriteria] = rawVal;
|
||||
break;
|
||||
case 'action':
|
||||
case 'description':
|
||||
case 'details':
|
||||
acc[k as keyof FullModLogCriteria] = rawVal.map((x: string) => parseStringToRegexOrLiteralSearch(x))
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export const authorCriteriaProperties = ['name', 'flairCssClass', 'flairText', 'flairTemplate', 'isMod', 'userNotes', 'modActions', 'age', 'linkKarma', 'commentKarma', 'totalKarma', 'verified', 'shadowBanned', 'description', 'isContributor'];
|
||||
|
||||
/**
|
||||
* Criteria with which to test against the author of an Activity. The outcome of the test is based on:
|
||||
@@ -159,6 +286,8 @@ export interface AuthorCriteria {
|
||||
* */
|
||||
userNotes?: UserNoteCriteria[]
|
||||
|
||||
modActions?: (ModNoteCriteria | ModLogCriteria)[]
|
||||
|
||||
/**
|
||||
* Test the age of the Author's account (when it was created) against this comparison
|
||||
*
|
||||
@@ -228,7 +357,35 @@ export interface AuthorCriteria {
|
||||
* Is the author an approved user (contributor)?
|
||||
* */
|
||||
isContributor?: boolean
|
||||
} // properties calculated/derived by CM -- not provided as plain values by reddit
|
||||
}
|
||||
|
||||
/**
|
||||
* When testing AuthorCriteria test properties in order of likelihood to require an API call to complete
|
||||
* */
|
||||
export const orderedAuthorCriteriaProps: (keyof AuthorCriteria)[] = [
|
||||
'name', // never needs an api call, returned/cached with activity info
|
||||
// none of these normally need api calls unless activity is a skeleton generated by CM (not normal)
|
||||
// all are part of cached activity data
|
||||
'flairCssClass',
|
||||
'flairText',
|
||||
'flairTemplate',
|
||||
// usernotes are cached longer than author by default (5 min vs 60 seconds)
|
||||
'userNotes',
|
||||
// requires fetching/getting cached author.
|
||||
// If fetching and user is shadowbanned none of the individual author data below will be retrievable either so always do this first
|
||||
'shadowBanned',
|
||||
// individual props require fetching/getting cached
|
||||
'age',
|
||||
'linkKarma',
|
||||
'commentKarma',
|
||||
'totalKarma',
|
||||
'verified',
|
||||
'description',
|
||||
'isMod', // requires fetching mods for subreddit
|
||||
'isContributor', // requires fetching contributors for subreddit
|
||||
'modActions', // requires fetching mod notes/actions for author (shortest cache TTL)
|
||||
];
|
||||
|
||||
export interface ActivityState {
|
||||
/**
|
||||
* * true/false => test whether Activity is removed or not
|
||||
|
||||
@@ -5,12 +5,12 @@ import {SentimentIntensityAnalyzer} from 'vader-sentiment';
|
||||
import wink from 'wink-sentiment';
|
||||
import {SnoowrapActivity} from "./Infrastructure/Reddit";
|
||||
import {
|
||||
asGenericComparison,
|
||||
asGenericComparison, comparisonTextOp,
|
||||
GenericComparison,
|
||||
parseGenericValueComparison,
|
||||
RangedComparison
|
||||
} from "./Infrastructure/Comparisons";
|
||||
import {asSubmission, between, comparisonTextOp, formatNumber} from "../util";
|
||||
import {asSubmission, between, formatNumber} from "../util";
|
||||
import {CMError, MaybeSeriousErrorWithCause} from "../Utils/Errors";
|
||||
import InvalidRegexError from "../Utils/InvalidRegexError";
|
||||
import {StringOperator} from "./Infrastructure/Atomic";
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import {MigrationInterface, QueryRunner, Table, TableIndex} from "typeorm"
|
||||
|
||||
const index = (prefix: string, columns: string[], unique = true) => new TableIndex({
|
||||
name: `IDX_${unique ? 'UN_' : ''}${prefix}_${columns.join('-')}_MIG`,
|
||||
columnNames: columns,
|
||||
isUnique: unique,
|
||||
});
|
||||
|
||||
const idIndex = (prefix: string, unique: boolean) => index(prefix, ['id'], unique);
|
||||
|
||||
export class indexes1653586738904 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
|
||||
queryRunner.connection.logger.logSchemaBuild('Starting Index Add/Update Migration');
|
||||
queryRunner.connection.logger.logSchemaBuild('IF YOU HAVE A LARGE DATABASE THIS MAY TAKE SEVERAL MINUTES! DO NOT STOP CONTEXTMOD WHILE MIGRATION IS IN PROGRESS!');
|
||||
|
||||
// unique ids due to random id
|
||||
const uniqueIdTableNames = [
|
||||
'Manager',
|
||||
'CMEvent',
|
||||
'FilterResult',
|
||||
'FilterCriteriaResult',
|
||||
'RunnableResult',
|
||||
'RulePremise',
|
||||
'RuleResult',
|
||||
'RuleSetResult',
|
||||
'ActionPremise',
|
||||
'ActionResult',
|
||||
'CheckResult',
|
||||
'RunResult'
|
||||
];
|
||||
|
||||
for (const tableName of uniqueIdTableNames) {
|
||||
const cmTable = await queryRunner.getTable(tableName);
|
||||
await queryRunner.createIndex(cmTable as Table, idIndex(tableName, true));
|
||||
}
|
||||
|
||||
// additional indexes
|
||||
|
||||
const actSource = await queryRunner.getTable('ActivitySource');
|
||||
await queryRunner.createIndex(actSource as Table, idIndex('ActivitySource', false));
|
||||
|
||||
const event = await queryRunner.getTable('CMEvent');
|
||||
await queryRunner.createIndices(event as Table, [index('CMEvent', ['activity_id'], false)]);
|
||||
|
||||
// FilterCriteriaResult criteriaId filterResultId
|
||||
|
||||
const fcrTable = await queryRunner.getTable('FilterCriteriaResult');
|
||||
await queryRunner.createIndices(fcrTable as Table, [
|
||||
index('FilterCriteriaResult', ['criteriaId'], false),
|
||||
index('FilterCriteriaResult', ['filterResultId'], false)
|
||||
]);
|
||||
|
||||
|
||||
// FilterCriteria id
|
||||
|
||||
const fcTable = await queryRunner.getTable('FilterCriteria');
|
||||
await queryRunner.createIndices(fcTable as Table, [
|
||||
idIndex('FilterCriteriaResult', false),
|
||||
]);
|
||||
|
||||
// RunnableResult resultId runnableId
|
||||
|
||||
const rrTable = await queryRunner.getTable('RunnableResult');
|
||||
await queryRunner.createIndices(rrTable as Table, [
|
||||
index('RunnableResult', ['resultId'], false),
|
||||
index('RunnableResult', ['runnableId'], false)
|
||||
]);
|
||||
|
||||
// ActionResult checkResultId premiseId
|
||||
|
||||
const arTable = await queryRunner.getTable('ActionResult');
|
||||
await queryRunner.createIndices(arTable as Table, [
|
||||
index('ActionResult', ['checkResultId'], false),
|
||||
index('ActionResult', ['premiseId'], false)
|
||||
]);
|
||||
|
||||
// CheckResult runId
|
||||
|
||||
const crTable = await queryRunner.getTable('CheckResult');
|
||||
await queryRunner.createIndices(crTable as Table, [
|
||||
index('CheckResult', ['runId'], false),
|
||||
]);
|
||||
|
||||
// RunResult eventId
|
||||
|
||||
const runResTable = await queryRunner.getTable('RunResult');
|
||||
await queryRunner.createIndices(runResTable as Table, [
|
||||
index('RunResult', ['eventId'], false),
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -3,7 +3,17 @@ import path from "path";
|
||||
import {FilterCriteriaDefaults} from "./Infrastructure/Filters/FilterShapes";
|
||||
|
||||
export const cacheOptDefaults = {ttl: 60, max: 500, checkPeriod: 600};
|
||||
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300, submissionTTL: 60, commentTTL: 60, filterCriteriaTTL: 60, subredditTTL: 600, selfTTL: 60};
|
||||
export const cacheTTLDefaults = {
|
||||
authorTTL: 60,
|
||||
userNotesTTL: 300,
|
||||
modNotesTTL: 60,
|
||||
wikiTTL: 300,
|
||||
submissionTTL: 60,
|
||||
commentTTL: 60,
|
||||
filterCriteriaTTL: 60,
|
||||
subredditTTL: 600,
|
||||
selfTTL: 60
|
||||
};
|
||||
|
||||
export const createHistoricalDisplayDefaults = (): HistoricalStatsDisplay => ({
|
||||
checksRunTotal: 0,
|
||||
|
||||
@@ -461,6 +461,17 @@ export interface TTLConfig {
|
||||
* @default 50
|
||||
* */
|
||||
selfTTL?: number | boolean
|
||||
|
||||
/**
|
||||
* Amount of time, in seconds, Mod Notes should be cached
|
||||
*
|
||||
* * If `0` or `true` will cache indefinitely (not recommended)
|
||||
* * If `false` will not cache
|
||||
*
|
||||
* @examples [60]
|
||||
* @default 60
|
||||
* */
|
||||
modNotesTTL?: number | boolean;
|
||||
}
|
||||
|
||||
export interface CacheConfig extends TTLConfig {
|
||||
@@ -737,6 +748,7 @@ export type StrongCache = {
|
||||
commentTTL: number | boolean,
|
||||
subredditTTL: number | boolean,
|
||||
selfTTL: number | boolean,
|
||||
modNotesTTL: number | boolean,
|
||||
filterCriteriaTTL: number | boolean,
|
||||
provider: CacheOptions
|
||||
actionedEventsMax?: number,
|
||||
|
||||
@@ -19,7 +19,8 @@ import {DispatchActionJson} from "../Action/DispatchAction";
|
||||
import {CancelDispatchActionJson} from "../Action/CancelDispatchAction";
|
||||
import {ContributorActionJson} from "../Action/ContributorAction";
|
||||
import {SentimentRuleJSONConfig} from "../Rule/SentimentRule";
|
||||
import {ModNoteActionJson} from "../Action/ModNoteAction";
|
||||
|
||||
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig
|
||||
|
||||
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | string;
|
||||
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | ModNoteActionJson | string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import {getAttributionIdentifier} from "../Utils/SnoowrapUtils";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
asSubmission, buildFilter, buildSubredditFilter,
|
||||
comparisonTextOp, convertSubredditsRawToStrong,
|
||||
convertSubredditsRawToStrong,
|
||||
FAIL,
|
||||
formatNumber, getActivitySubredditName, isActivityWindowConfig, isSubmission,
|
||||
parseSubredditName,
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
HistoryFiltersOptions
|
||||
} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {FilterOptions} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
|
||||
|
||||
export interface AttributionCriteria {
|
||||
|
||||
@@ -8,7 +8,6 @@ import Submission from "snoowrap/dist/objects/Submission";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
asSubmission,
|
||||
comparisonTextOp,
|
||||
FAIL,
|
||||
formatNumber, getActivitySubredditName, historyFilterConfigToOptions, isSubmission,
|
||||
parseSubredditName,
|
||||
@@ -20,7 +19,7 @@ import {SubredditCriteria} from "../Common/Infrastructure/Filters/FilterCriteria
|
||||
import {CompareValueOrPercent} from "../Common/Infrastructure/Atomic";
|
||||
import {ActivityWindowConfig, ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
|
||||
export interface CommentThresholdCriteria extends ThresholdCriteria {
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
asSubmission, bitsToHexLength,
|
||||
// blockHashImage,
|
||||
compareImages,
|
||||
comparisonTextOp, convertSubredditsRawToStrong,
|
||||
convertSubredditsRawToStrong,
|
||||
FAIL,
|
||||
formatNumber,
|
||||
getActivitySubredditName, imageCompareMaxConcurrencyGuess,
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
SubredditCriteria
|
||||
} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
|
||||
const parseLink = parseUsableLinkIdentifier();
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Comment} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {
|
||||
asSubmission,
|
||||
comparisonTextOp, FAIL, isExternalUrlSubmission, isSubmission, parseRegex, parseStringToRegex,
|
||||
FAIL, isExternalUrlSubmission, isSubmission, parseRegex, parseStringToRegex,
|
||||
PASS, triggeredIndicator, windowConfigToWindowCriteria
|
||||
} from "../util";
|
||||
import {
|
||||
@@ -13,7 +13,11 @@ import dayjs from 'dayjs';
|
||||
import {SimpleError} from "../Utils/Errors";
|
||||
import {JoinOperands} from "../Common/Infrastructure/Atomic";
|
||||
import {ActivityWindowConfig} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {parseGenericValueComparison, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {
|
||||
comparisonTextOp,
|
||||
parseGenericValueComparison,
|
||||
parseGenericValueOrPercentComparison
|
||||
} from "../Common/Infrastructure/Comparisons";
|
||||
|
||||
export interface RegexCriteria {
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,6 @@ import {Comment, RedditUser} from "snoowrap";
|
||||
import {
|
||||
activityWindowText,
|
||||
asSubmission,
|
||||
comparisonTextOp,
|
||||
FAIL,
|
||||
getActivitySubredditName, isActivityWindowConfig,
|
||||
isExternalUrlSubmission,
|
||||
@@ -29,7 +28,7 @@ import {
|
||||
ActivityWindowCriteria,
|
||||
HistoryFiltersOptions
|
||||
} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {parseGenericValueComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {comparisonTextOp, parseGenericValueComparison} from "../Common/Infrastructure/Comparisons";
|
||||
|
||||
const parseUsableLinkIdentifier = linkParser();
|
||||
|
||||
|
||||
@@ -3,10 +3,8 @@ import {Listing, SearchOptions} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import Comment from "snoowrap/dist/objects/Comment";
|
||||
import {
|
||||
compareDurationValue,
|
||||
comparisonTextOp,
|
||||
FAIL, formatNumber,
|
||||
isRepostItemResult, parseDurationComparison, parseUsableLinkIdentifier,
|
||||
isRepostItemResult, parseUsableLinkIdentifier,
|
||||
PASS, searchAndReplace, stringSameness, triggeredIndicator, windowConfigToWindowCriteria, wordCount
|
||||
} from "../util";
|
||||
import {
|
||||
@@ -22,7 +20,11 @@ import dayjs from "dayjs";
|
||||
import {rest} from "lodash";
|
||||
import {CompareValue, DurationComparor, JoinOperands, SearchFacetType} from "../Common/Infrastructure/Atomic";
|
||||
import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {parseGenericValueComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {
|
||||
compareDurationValue, comparisonTextOp,
|
||||
parseDurationComparison,
|
||||
parseGenericValueComparison
|
||||
} from "../Common/Infrastructure/Comparisons";
|
||||
|
||||
const parseYtIdentifier = parseUsableLinkIdentifier();
|
||||
|
||||
|
||||
@@ -2,13 +2,14 @@ import {Rule, RuleJSONConfig, RuleOptions} from "./index";
|
||||
import {Comment} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {
|
||||
comparisonTextOp, formatNumber,
|
||||
formatNumber,
|
||||
triggeredIndicator, windowConfigToWindowCriteria
|
||||
} from "../util";
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import {map as mapAsync} from 'async';
|
||||
import {
|
||||
comparisonTextOp,
|
||||
GenericComparison,
|
||||
parseGenericValueOrPercentComparison,
|
||||
RangedComparison
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"flair",
|
||||
"lock",
|
||||
"message",
|
||||
"modnote",
|
||||
"remove",
|
||||
"report",
|
||||
"userflair",
|
||||
@@ -137,6 +138,19 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"modActions": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ModLogCriteria"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
@@ -405,6 +419,241 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModLogCriteria": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModNoteCriteria": {
|
||||
"properties": {
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noteType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModeratorNameCriteria": {
|
||||
"properties": {
|
||||
"behavior": {
|
||||
@@ -660,16 +909,16 @@
|
||||
"properties": {
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
|
||||
@@ -651,6 +651,19 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"modActions": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ModLogCriteria"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
@@ -967,6 +980,17 @@
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"modNotesTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, Mod Notes should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1419,6 +1443,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ContributorActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -3481,6 +3508,366 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModLogCriteria": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModNoteActionJson": {
|
||||
"description": "Add a Toolbox User Note to the Author of this Activity",
|
||||
"properties": {
|
||||
"allowDuplicate": {
|
||||
"default": false,
|
||||
"description": "Add Note even if a Note already exists for this Activity",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"authorIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AuthorCriteria"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
|
||||
}
|
||||
],
|
||||
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
|
||||
},
|
||||
"content": {
|
||||
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
|
||||
"examples": [
|
||||
"This is the content of a comment/report/usernote",
|
||||
"this is **bold** markdown text",
|
||||
"wiki:botconfig/acomment"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CommentState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
|
||||
}
|
||||
],
|
||||
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The type of action that will be performed",
|
||||
"enum": [
|
||||
"modnote"
|
||||
],
|
||||
"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"
|
||||
},
|
||||
"referenceActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModNoteCriteria": {
|
||||
"properties": {
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noteType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModeratorNameCriteria": {
|
||||
"properties": {
|
||||
"behavior": {
|
||||
@@ -5367,6 +5754,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ContributorActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -6035,16 +6425,16 @@
|
||||
"properties": {
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
|
||||
@@ -119,6 +119,19 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"modActions": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ModLogCriteria"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
@@ -965,6 +978,241 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModLogCriteria": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModNoteCriteria": {
|
||||
"properties": {
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noteType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModeratorNameCriteria": {
|
||||
"properties": {
|
||||
"behavior": {
|
||||
@@ -1226,6 +1474,17 @@
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"modNotesTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, Mod Notes should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": [
|
||||
"number",
|
||||
"boolean"
|
||||
]
|
||||
},
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1749,16 +2008,16 @@
|
||||
"properties": {
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
|
||||
@@ -577,6 +577,19 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"modActions": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ModLogCriteria"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
@@ -1642,6 +1655,241 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModLogCriteria": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModNoteCriteria": {
|
||||
"properties": {
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noteType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModeratorNameCriteria": {
|
||||
"properties": {
|
||||
"behavior": {
|
||||
@@ -3169,16 +3417,16 @@
|
||||
"properties": {
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
|
||||
@@ -548,6 +548,19 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"modActions": {
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/ModLogCriteria"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"name": {
|
||||
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
@@ -1613,6 +1626,241 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModLogCriteria": {
|
||||
"properties": {
|
||||
"action": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"details": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModNoteCriteria": {
|
||||
"properties": {
|
||||
"activityType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noteType": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"ABUSE_WARNING",
|
||||
"BAN",
|
||||
"BOT_BAN",
|
||||
"HELPFUL_USER",
|
||||
"PERMA_BAN",
|
||||
"SOLID_CONTRIBUTOR",
|
||||
"SPAM_WARNING",
|
||||
"SPAM_WATCH"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
"total"
|
||||
],
|
||||
"examples": [
|
||||
"current"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"APPROVAL",
|
||||
"INVITE",
|
||||
"NOTE",
|
||||
"REMOVAL",
|
||||
"SPAM"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ModeratorNameCriteria": {
|
||||
"properties": {
|
||||
"behavior": {
|
||||
@@ -3140,16 +3388,16 @@
|
||||
"properties": {
|
||||
"count": {
|
||||
"default": ">= 1",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [ascending|descending]`",
|
||||
"description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign] [in timeRange] [ascending|descending]`\n\nIf `timeRange` is given then only notes/mod actions that occur between timeRange and NOW will be returned. `timeRange` is ignored if search is `current`",
|
||||
"examples": [
|
||||
">= 1"
|
||||
],
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"search": {
|
||||
"default": "current",
|
||||
"description": "How to test the notes for this Author:\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
|
||||
"enum": [
|
||||
"consecutive",
|
||||
"current",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Snoowrap, {Comment, Subreddit, WikiPage} from "snoowrap";
|
||||
import Snoowrap, {WikiPage} from "snoowrap";
|
||||
import {Logger} from "winston";
|
||||
import {SubmissionCheck} from "../Check/SubmissionCheck";
|
||||
import {CommentCheck} from "../Check/CommentCheck";
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
SYSTEM,
|
||||
USER, RuleResult, DatabaseStatisticsOperatorConfig
|
||||
} from "../Common/interfaces";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {Submission, Comment, Subreddit} from 'snoowrap/dist/objects';
|
||||
import {activityIsRemoved, ItemContent, itemContentPeek} from "../Utils/SnoowrapUtils";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
|
||||
53
src/Subreddit/ModNotes/ModAction.ts
Normal file
53
src/Subreddit/ModNotes/ModAction.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import {Submission, RedditUser, Comment, Subreddit, PrivateMessage} from "snoowrap/dist/objects"
|
||||
import {generateSnoowrapEntityFromRedditThing, parseRedditFullname} from "../../util"
|
||||
import Snoowrap from "snoowrap";
|
||||
|
||||
//import {ExtendedSnoowrap} from "../../Utils/SnoowrapClients";
|
||||
|
||||
export interface ModActionRaw {
|
||||
action?: string | null
|
||||
reddit_id?: string | null
|
||||
details?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export class ModAction {
|
||||
action?: string
|
||||
actedOn?: RedditUser | Submission | Comment | Subreddit | PrivateMessage
|
||||
details?: string
|
||||
description?: string
|
||||
|
||||
constructor(data: ModActionRaw | undefined, client: Snoowrap) {
|
||||
const {
|
||||
action,
|
||||
reddit_id,
|
||||
details,
|
||||
description
|
||||
} = data || {};
|
||||
this.action = action !== null ? action : undefined;
|
||||
this.details = details !== null ? details : undefined;
|
||||
this.description = description !== null ? description : undefined;
|
||||
|
||||
if (reddit_id !== null && reddit_id !== undefined) {
|
||||
const thing = parseRedditFullname(reddit_id);
|
||||
if (thing !== undefined) {
|
||||
this.actedOn = generateSnoowrapEntityFromRedditThing(thing, client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toRaw(): ModActionRaw {
|
||||
return {
|
||||
action: this.action,
|
||||
details: this.details,
|
||||
reddit_id: this.actedOn !== undefined ? this.actedOn.id : undefined,
|
||||
description: this.description
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.toRaw();
|
||||
}
|
||||
}
|
||||
|
||||
export default ModAction;
|
||||
119
src/Subreddit/ModNotes/ModNote.ts
Normal file
119
src/Subreddit/ModNotes/ModNote.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {ModAction, ModActionRaw} from "./ModAction";
|
||||
import {Submission, RedditUser, Comment, Subreddit} from "snoowrap/dist/objects"
|
||||
import {ModUserNote, ModUserNoteRaw} from "./ModUserNote";
|
||||
//import {ExtendedSnoowrap} from "../../Utils/SnoowrapClients";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {generateSnoowrapEntityFromRedditThing, parseRedditFullname} from "../../util";
|
||||
import Snoowrap from "snoowrap";
|
||||
import {ModActionType, ModUserNoteLabel} from "../../Common/Infrastructure/Atomic";
|
||||
import {RedditThing} from "../../Common/Infrastructure/Reddit";
|
||||
|
||||
export interface ModNoteSnoowrapPopulated extends Omit<ModNoteRaw, 'subreddit' | 'user'> {
|
||||
subreddit: Subreddit
|
||||
user: RedditUser
|
||||
}
|
||||
|
||||
export interface CreateModNoteData {
|
||||
user: RedditUser
|
||||
subreddit: Subreddit
|
||||
activity?: Submission | Comment | RedditUser
|
||||
label?: ModUserNoteLabel
|
||||
note?: string
|
||||
}
|
||||
|
||||
export const asCreateModNoteData = (val: any): val is CreateModNoteData => {
|
||||
if(val !== null && typeof val === 'object') {
|
||||
return val.user instanceof RedditUser && val.subreddit instanceof Subreddit && typeof val.note === 'string';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
export interface ModNoteRaw {
|
||||
subreddit: string
|
||||
subreddit_id: string
|
||||
|
||||
user: string
|
||||
user_id: string
|
||||
|
||||
operator: string
|
||||
operator_id: string
|
||||
|
||||
id: string
|
||||
created_at: number
|
||||
cursor?: string
|
||||
type: ModActionType | string
|
||||
mod_action_data: ModActionRaw
|
||||
user_note_data: ModUserNoteRaw
|
||||
}
|
||||
|
||||
export class ModNote {
|
||||
|
||||
createdBy: RedditUser | Subreddit
|
||||
createdByName?: string
|
||||
createdAt: Dayjs
|
||||
action: ModAction
|
||||
note: ModUserNote
|
||||
user: RedditUser
|
||||
operatorVal: string
|
||||
cursor?: string
|
||||
id: string
|
||||
subreddit: Subreddit
|
||||
type: ModActionType | string
|
||||
|
||||
|
||||
constructor(data: ModNoteRaw, client: Snoowrap) {
|
||||
|
||||
this.createdByName = data.operator;
|
||||
this.createdAt = dayjs.unix(data.created_at);
|
||||
this.id = data.id;
|
||||
this.type = data.type;
|
||||
this.cursor = data.cursor;
|
||||
|
||||
this.subreddit = new Subreddit({display_name: data.subreddit, id: data.subreddit_id}, client, false);
|
||||
this.user = new RedditUser({name: data.user, id: data.user_id}, client, false);
|
||||
|
||||
this.operatorVal = data.operator;
|
||||
|
||||
const opThing = parseRedditFullname(data.operator_id) as RedditThing;
|
||||
this.createdBy = generateSnoowrapEntityFromRedditThing(opThing, client) as RedditUser | Subreddit;
|
||||
if (this.createdBy instanceof RedditUser) {
|
||||
this.createdBy.name = data.operator;
|
||||
}
|
||||
|
||||
this.action = new ModAction(data.mod_action_data, client);
|
||||
if (this.action.actedOn instanceof RedditUser && this.action.actedOn.id === this.user.id) {
|
||||
this.action.actedOn = this.user;
|
||||
}
|
||||
|
||||
this.note = new ModUserNote(data.user_note_data, client);
|
||||
if (this.note.actedOn instanceof RedditUser && this.note.actedOn.id === this.user.id) {
|
||||
this.note.actedOn = this.user;
|
||||
}
|
||||
}
|
||||
|
||||
toRaw(): ModNoteRaw {
|
||||
return {
|
||||
subreddit: this.subreddit.display_name,
|
||||
subreddit_id: this.subreddit.id,
|
||||
|
||||
user: this.user.name,
|
||||
user_id: this.user.id,
|
||||
|
||||
operator: this.operatorVal,
|
||||
operator_id: this.createdBy.id,
|
||||
|
||||
mod_action_data: this.action.toRaw(),
|
||||
|
||||
id: this.id,
|
||||
user_note_data: this.note.toRaw(),
|
||||
created_at: this.createdAt.unix(),
|
||||
type: this.type,
|
||||
cursor: this.cursor
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.toRaw();
|
||||
}
|
||||
}
|
||||
48
src/Subreddit/ModNotes/ModUserNote.ts
Normal file
48
src/Subreddit/ModNotes/ModUserNote.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {Comment, PrivateMessage, RedditUser, Submission} from "snoowrap/dist/objects";
|
||||
import {ModUserNoteLabel} from "../../Common/Infrastructure/Atomic";
|
||||
//import {ExtendedSnoowrap} from "../../Utils/SnoowrapClients";
|
||||
import {generateSnoowrapEntityFromRedditThing, parseRedditFullname} from "../../util";
|
||||
import Snoowrap from "snoowrap";
|
||||
|
||||
export interface ModUserNoteRaw {
|
||||
note?: string | null
|
||||
reddit_id?: string | null
|
||||
label?: string | null
|
||||
}
|
||||
|
||||
export class ModUserNote {
|
||||
note?: string
|
||||
actedOn?: RedditUser | Submission | Comment | PrivateMessage
|
||||
label?: ModUserNoteLabel
|
||||
|
||||
constructor(data: ModUserNoteRaw | undefined, client: Snoowrap) {
|
||||
const {
|
||||
note,
|
||||
reddit_id,
|
||||
label
|
||||
} = data || {};
|
||||
this.note = note !== null ? note : undefined;
|
||||
this.label = label !== null ? label as ModUserNoteLabel : undefined;
|
||||
|
||||
if (reddit_id !== null && reddit_id !== undefined) {
|
||||
const thing = parseRedditFullname(reddit_id);
|
||||
if (thing !== undefined) {
|
||||
this.actedOn = generateSnoowrapEntityFromRedditThing(thing, client) as RedditUser | Submission | Comment;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toRaw(): ModUserNoteRaw {
|
||||
return {
|
||||
note: this.note,
|
||||
reddit_id: this.actedOn !== undefined ? this.actedOn.id : undefined,
|
||||
label: this.label
|
||||
}
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.toRaw();
|
||||
}
|
||||
}
|
||||
|
||||
export default ModUserNote;
|
||||
@@ -17,8 +17,6 @@ import {
|
||||
buildCacheOptionsFromProvider,
|
||||
buildCachePrefix,
|
||||
cacheStats,
|
||||
compareDurationValue,
|
||||
comparisonTextOp,
|
||||
createCacheManager,
|
||||
escapeRegex,
|
||||
FAIL,
|
||||
@@ -35,7 +33,6 @@ import {
|
||||
isUser,
|
||||
hashString,
|
||||
mergeArr,
|
||||
parseDurationComparison,
|
||||
parseExternalUrl,
|
||||
parseRedditEntity,
|
||||
parseStringToRegex,
|
||||
@@ -56,7 +53,12 @@ import {
|
||||
frequencyEqualOrLargerThanMin,
|
||||
parseDurationValToDuration,
|
||||
windowConfigToWindowCriteria,
|
||||
asStrongSubredditState, convertSubredditsRawToStrong, filterByTimeRequirement
|
||||
asStrongSubredditState,
|
||||
convertSubredditsRawToStrong,
|
||||
filterByTimeRequirement,
|
||||
asSubreddit,
|
||||
modActionCriteriaSummary,
|
||||
parseRedditFullname
|
||||
} from "../util";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
@@ -109,16 +111,19 @@ import {RuleSetResultEntity} from "../Common/Entities/RuleSetResultEntity";
|
||||
import {RulePremise} from "../Common/Entities/RulePremise";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import {
|
||||
AuthorCriteria, CommentState, RequiredAuthorCrit,
|
||||
asModLogCriteria,
|
||||
asModNoteCriteria,
|
||||
AuthorCriteria, CommentState, ModLogCriteria, ModNoteCriteria, orderedAuthorCriteriaProps, RequiredAuthorCrit,
|
||||
StrongSubredditCriteria, SubmissionState,
|
||||
SubredditCriteria, TypedActivityState, TypedActivityStates,
|
||||
SubredditCriteria, toFullModLogCriteria, toFullModNoteCriteria, TypedActivityState, TypedActivityStates,
|
||||
UserNoteCriteria
|
||||
} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
import {
|
||||
ActivitySource, DurationVal,
|
||||
EventRetentionPolicyRange,
|
||||
JoinOperands,
|
||||
ModeratorNameCriteria, statFrequencies, StatisticFrequency,
|
||||
ModActionType,
|
||||
ModeratorNameCriteria, ModUserNoteLabel, statFrequencies, StatisticFrequency,
|
||||
StatisticFrequencyOption
|
||||
} from "../Common/Infrastructure/Atomic";
|
||||
import {
|
||||
@@ -137,13 +142,20 @@ import {
|
||||
import {Duration} from "dayjs/plugin/duration";
|
||||
import {
|
||||
|
||||
ActivityType,
|
||||
AuthorHistorySort,
|
||||
CachedFetchedActivitiesResult, FetchedActivitiesResult,
|
||||
SnoowrapActivity
|
||||
} from "../Common/Infrastructure/Reddit";
|
||||
import {AuthorCritPropHelper} from "../Common/Infrastructure/Filters/AuthorCritPropHelper";
|
||||
import {NoopLogger} from "../Utils/loggerFactory";
|
||||
import {parseGenericValueComparison, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {
|
||||
compareDurationValue, comparisonTextOp,
|
||||
parseDurationComparison,
|
||||
parseGenericValueComparison,
|
||||
parseGenericValueOrPercentComparison
|
||||
} from "../Common/Infrastructure/Comparisons";
|
||||
import {asCreateModNoteData, CreateModNoteData, ModNote, ModNoteRaw} from "./ModNotes/ModNote";
|
||||
|
||||
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.';
|
||||
|
||||
@@ -209,6 +221,7 @@ export class SubredditResources {
|
||||
protected submissionTTL: number | false = cacheTTLDefaults.submissionTTL;
|
||||
protected commentTTL: number | false = cacheTTLDefaults.commentTTL;
|
||||
protected filterCriteriaTTL: number | false = cacheTTLDefaults.filterCriteriaTTL;
|
||||
protected modNotesTTL: number | false = cacheTTLDefaults.modNotesTTL;
|
||||
public selfTTL: number | false = cacheTTLDefaults.selfTTL;
|
||||
name: string;
|
||||
botName: string;
|
||||
@@ -258,6 +271,7 @@ export class SubredditResources {
|
||||
submissionTTL,
|
||||
commentTTL,
|
||||
subredditTTL,
|
||||
modNotesTTL,
|
||||
},
|
||||
botName,
|
||||
database,
|
||||
@@ -299,6 +313,7 @@ export class SubredditResources {
|
||||
this.subredditTTL = subredditTTL === true ? 0 : subredditTTL;
|
||||
this.wikiTTL = wikiTTL === true ? 0 : wikiTTL;
|
||||
this.filterCriteriaTTL = filterCriteriaTTL === true ? 0 : filterCriteriaTTL;
|
||||
this.modNotesTTL = modNotesTTL === true ? 0 : modNotesTTL;
|
||||
this.selfTTL = selfTTL === true ? 0 : selfTTL;
|
||||
this.subreddit = subreddit;
|
||||
this.thirdPartyCredentials = thirdPartyCredentials;
|
||||
@@ -1003,11 +1018,19 @@ export class SubredditResources {
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
async getSubreddit(item: Submission | Comment, logger = this.logger) {
|
||||
async getSubreddit(item: Submission | Comment | Subreddit | string, logger = this.logger) {
|
||||
let subName = '';
|
||||
if (typeof item === 'string') {
|
||||
subName = item;
|
||||
} else if (asSubreddit(item)) {
|
||||
subName = item.display_name;
|
||||
} else if (asSubmission(item) || asComment(item)) {
|
||||
subName = getActivitySubredditName(item);
|
||||
}
|
||||
try {
|
||||
let hash = '';
|
||||
const subName = getActivitySubredditName(item);
|
||||
if (this.subredditTTL !== false) {
|
||||
|
||||
hash = `sub-${subName}`;
|
||||
await this.stats.cache.subreddit.identifierRequestCount.set(hash, (await this.stats.cache.subreddit.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
this.stats.cache.subreddit.requestTimestamps.push(Date.now());
|
||||
@@ -1018,7 +1041,7 @@ export class SubredditResources {
|
||||
return new Subreddit(cachedSubreddit, this.client, false);
|
||||
}
|
||||
// @ts-ignore
|
||||
const subreddit = await this.client.getSubreddit(subName).fetch() as Subreddit;
|
||||
const subreddit = await (item instanceof Subreddit ? item : this.client.getSubreddit(subName)).fetch() as Subreddit;
|
||||
this.stats.cache.subreddit.miss++;
|
||||
// @ts-ignore
|
||||
await this.cache.set(hash, subreddit, {ttl: this.subredditTTL});
|
||||
@@ -1026,7 +1049,7 @@ export class SubredditResources {
|
||||
return subreddit as Subreddit;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
let subreddit = await this.client.getSubreddit(subName);
|
||||
let subreddit = await (item instanceof Subreddit ? item : this.client.getSubreddit(subName)).fetch();
|
||||
|
||||
return subreddit as Subreddit;
|
||||
}
|
||||
@@ -1132,6 +1155,84 @@ export class SubredditResources {
|
||||
return false;
|
||||
}
|
||||
|
||||
async getAuthorModNotesByActivityAuthor(activity: Comment | Submission) {
|
||||
const author = activity.author instanceof RedditUser ? activity.author : getActivityAuthorName(activity.author);
|
||||
if (activity.subreddit.display_name !== this.subreddit.display_name) {
|
||||
throw new SimpleError(`Can only get Modnotes for current moderator subreddit, Activity is from ${activity.subreddit.display_name}`, {isSerious: false});
|
||||
}
|
||||
return this.getAuthorModNotes(author);
|
||||
}
|
||||
|
||||
async getAuthorModNotes(val: RedditUser | string) {
|
||||
|
||||
const authorName = typeof val === 'string' ? val : val.name;
|
||||
if (authorName === '[deleted]') {
|
||||
throw new SimpleError(`User is '[deleted]', cannot retrieve`, {isSerious: false});
|
||||
}
|
||||
const subredditName = this.subreddit.display_name
|
||||
|
||||
const hash = `authorModNotes-${subredditName}-${authorName}`;
|
||||
|
||||
if (this.modNotesTTL !== false) {
|
||||
const cachedModNoteData = await this.cache.get(hash) as ModNoteRaw[] | null | undefined;
|
||||
if (cachedModNoteData !== undefined && cachedModNoteData !== null) {
|
||||
this.logger.debug(`Cache Hit: Author ModNotes ${authorName} in ${subredditName}`);
|
||||
|
||||
return cachedModNoteData.map(x => {
|
||||
const note = new ModNote(x, this.client);
|
||||
note.subreddit = this.subreddit;
|
||||
if (val instanceof RedditUser) {
|
||||
note.user = val;
|
||||
}
|
||||
return note;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const fetchedNotes = (await this.client.getModNotes(this.subreddit, val)).notes.map(x => {
|
||||
x.subreddit = this.subreddit;
|
||||
if (val instanceof RedditUser) {
|
||||
x.user = val;
|
||||
}
|
||||
return x;
|
||||
});
|
||||
|
||||
if (this.modNotesTTL !== false) {
|
||||
// @ts-ignore
|
||||
await this.cache.set(hash, fetchedNotes, {ttl: this.modNotesTTL});
|
||||
}
|
||||
|
||||
return fetchedNotes;
|
||||
}
|
||||
|
||||
async addModNote(note: CreateModNoteData | ModNote): Promise<ModNote> {
|
||||
let data: CreateModNoteData;
|
||||
if (asCreateModNoteData(note)) {
|
||||
data = note;
|
||||
} else {
|
||||
data = {
|
||||
user: note.user,
|
||||
subreddit: this.subreddit,
|
||||
activity: note.note.actedOn as Submission | Comment | RedditUser | undefined,
|
||||
label: note.note.label,
|
||||
note: note.note.note ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
const newNote = await this.client.addModNote(data);
|
||||
|
||||
if (this.modNotesTTL !== false) {
|
||||
const hash = `authorModNotes-${this.subreddit.display_name}-${data.user.name}`;
|
||||
const cachedModNoteData = await this.cache.get(hash) as ModNoteRaw[] | null | undefined;
|
||||
if (cachedModNoteData !== undefined && cachedModNoteData !== null) {
|
||||
this.logger.debug(`Adding new Note ${newNote.id} to Author ${data.user.name} Note cache`);
|
||||
await this.cache.set(hash, [newNote, ...cachedModNoteData], {ttl: this.modNotesTTL});
|
||||
}
|
||||
}
|
||||
|
||||
return newNote;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
async getAuthor(val: RedditUser | string) {
|
||||
const authorName = typeof val === 'string' ? val : val.name;
|
||||
@@ -1174,7 +1275,7 @@ export class SubredditResources {
|
||||
return user;
|
||||
} catch (err) {
|
||||
if(isStatusError(err) && err.statusCode === 404) {
|
||||
throw new SimpleError(`Reddit returned a 404 for User '${authorName}'. Likely this user is shadowbanned.`, {isSerious: false});
|
||||
throw new SimpleError(`Reddit returned a 404 for User '${authorName}'. Likely this user is shadowbanned.`, {isSerious: false, code: 404});
|
||||
}
|
||||
throw new ErrorWithCause(`Could not retrieve User '${authorName}'`, {cause: err});
|
||||
}
|
||||
@@ -2150,7 +2251,7 @@ export class SubredditResources {
|
||||
propResultsMap.age!.found = created.format('MMMM D, YYYY h:mm A Z');
|
||||
break;
|
||||
case 'title':
|
||||
if((item instanceof Comment)) {
|
||||
if(asComment(item)) {
|
||||
const titleWarn ='`title` is not allowed in `itemIs` criteria when the main Activity is a Comment';
|
||||
log.debug(titleWarn);
|
||||
propResultsMap.title!.passed = true;
|
||||
@@ -2169,7 +2270,7 @@ export class SubredditResources {
|
||||
}
|
||||
break;
|
||||
case 'isRedditMediaDomain':
|
||||
if((item instanceof Comment)) {
|
||||
if(asComment(item)) {
|
||||
const mediaWarn = '`isRedditMediaDomain` is not allowed in `itemIs` criteria when the main Activity is a Comment';
|
||||
log.debug(mediaWarn);
|
||||
propResultsMap.isRedditMediaDomain!.passed = true;
|
||||
@@ -2256,7 +2357,7 @@ export class SubredditResources {
|
||||
propResultsMap[k]!.passed = criteriaPassWithIncludeBehavior(propResultsMap[k]!.found === itemOptVal, include);
|
||||
break;
|
||||
case 'op':
|
||||
if(isSubmission(item)) {
|
||||
if(asSubmission(item)) {
|
||||
const opWarn = `On a Submission the 'op' property will always be true. Did you mean to use this on a comment instead?`;
|
||||
log.debug(opWarn);
|
||||
propResultsMap.op!.passed = true;
|
||||
@@ -2267,7 +2368,7 @@ export class SubredditResources {
|
||||
propResultsMap.op!.passed = criteriaPassWithIncludeBehavior(propResultsMap.op!.found === itemOptVal, include);
|
||||
break;
|
||||
case 'depth':
|
||||
if(isSubmission(item)) {
|
||||
if(asSubmission(item)) {
|
||||
const depthWarn = `Cannot test for 'depth' on a Submission`;
|
||||
log.debug(depthWarn);
|
||||
propResultsMap.depth!.passed = true;
|
||||
@@ -2378,6 +2479,8 @@ export class SubredditResources {
|
||||
ex = v.map(x => {
|
||||
if (asUserNoteCriteria(x)) {
|
||||
return userNoteCriteriaSummary(x);
|
||||
} else if(asModNoteCriteria(x) || asModLogCriteria(x)) {
|
||||
return modActionCriteriaSummary(x);
|
||||
}
|
||||
return x;
|
||||
});
|
||||
@@ -2391,45 +2494,29 @@ export class SubredditResources {
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const {shadowBanned} = authorOpts;
|
||||
const keys = Object.keys(propResultsMap) as (keyof AuthorCriteria)[]
|
||||
let orderedKeys: (keyof AuthorCriteria)[] = [];
|
||||
|
||||
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;
|
||||
}
|
||||
// push existing keys that should be ordered to the front of the list
|
||||
for(const oProp of orderedAuthorCriteriaProps) {
|
||||
if(keys.includes(oProp)) {
|
||||
orderedKeys.push(oProp);
|
||||
}
|
||||
}
|
||||
|
||||
// then add any keys not included as ordered but that exist onto the end of the list
|
||||
// this way when we iterate all properties of the criteria we test all props that (probably) don't require API calls first
|
||||
orderedKeys = orderedKeys.concat(keys.filter(x => !orderedKeys.includes(x)));
|
||||
|
||||
|
||||
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;
|
||||
for (const k of orderedKeys) {
|
||||
|
||||
if(propResultsMap.shadowBanned !== undefined && propResultsMap.shadowBanned!.found === true) {
|
||||
// if we've determined the user is shadowbanned we can't get any info about them anyways so end criteria testing early
|
||||
break;
|
||||
}
|
||||
|
||||
// none of the criteria below are returned if the user is suspended
|
||||
@@ -2454,8 +2541,30 @@ export class SubredditResources {
|
||||
|
||||
const authorOptVal = definedAuthorOpts[k];
|
||||
|
||||
//if (authorOpts[k] !== undefined) {
|
||||
switch (k) {
|
||||
case 'shadowBanned':
|
||||
|
||||
const isShadowBannedTest = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await user();
|
||||
return false;
|
||||
} catch (err: any) {
|
||||
// see this.getAuthor() catch block
|
||||
if('code' in err && err.code === 404) {
|
||||
return true
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
propResultsMap.shadowBanned!.found = await isShadowBannedTest();
|
||||
const shadowPassed = (propResultsMap.shadowBanned!.found && authorOptVal === true) || (!propResultsMap.shadowBanned!.found && authorOptVal === false);
|
||||
propResultsMap.shadowBanned!.passed = criteriaPassWithIncludeBehavior(shadowPassed, include);
|
||||
if(propResultsMap.shadowBanned!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
break;
|
||||
case 'name':
|
||||
const nameVal = authorOptVal as RequiredAuthorCrit['name'];
|
||||
const authPass = () => {
|
||||
@@ -2469,7 +2578,7 @@ export class SubredditResources {
|
||||
}
|
||||
const authResult = authPass();
|
||||
propResultsMap.name!.found = authorName;
|
||||
propResultsMap.name!.passed = !((include && !authResult) || (!include && authResult));
|
||||
propResultsMap.name!.passed = criteriaPassWithIncludeBehavior(authResult, include);
|
||||
if (!propResultsMap.name!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2494,7 +2603,7 @@ export class SubredditResources {
|
||||
cssResult = opts.some(x => x.trim().toLowerCase() === css.trim().toLowerCase())
|
||||
}
|
||||
|
||||
propResultsMap.flairCssClass!.passed = !((include && !cssResult) || (!include && cssResult));
|
||||
propResultsMap.flairCssClass!.passed = criteriaPassWithIncludeBehavior(cssResult, include);
|
||||
if (!propResultsMap.flairCssClass!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2518,7 +2627,7 @@ export class SubredditResources {
|
||||
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));
|
||||
propResultsMap.flairText!.passed = criteriaPassWithIncludeBehavior(textResult, include);
|
||||
if (!propResultsMap.flairText!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2542,7 +2651,7 @@ export class SubredditResources {
|
||||
templateResult = opts.some(x => x.trim() === templateId);
|
||||
}
|
||||
|
||||
propResultsMap.flairTemplate!.passed = !((include && !templateResult) || (!include && templateResult));
|
||||
propResultsMap.flairTemplate!.passed = criteriaPassWithIncludeBehavior(templateResult, include);
|
||||
if (!propResultsMap.flairTemplate!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2552,7 +2661,7 @@ export class SubredditResources {
|
||||
const isModerator = mods.some(x => x.name === authorName) || authorName.toLowerCase() === 'automoderator';
|
||||
const modMatch = authorOptVal === isModerator;
|
||||
propResultsMap.isMod!.found = isModerator;
|
||||
propResultsMap.isMod!.passed = !((include && !modMatch) || (!include && modMatch));
|
||||
propResultsMap.isMod!.passed = criteriaPassWithIncludeBehavior(modMatch, include);
|
||||
if (!propResultsMap.isMod!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2562,7 +2671,7 @@ export class SubredditResources {
|
||||
const isContributor= contributors.some(x => x.name === authorName);
|
||||
const contributorMatch = authorOptVal === isContributor;
|
||||
propResultsMap.isContributor!.found = isContributor;
|
||||
propResultsMap.isContributor!.passed = !((include && !contributorMatch) || (!include && contributorMatch));
|
||||
propResultsMap.isContributor!.passed = criteriaPassWithIncludeBehavior(contributorMatch, include);
|
||||
if (!propResultsMap.isContributor!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2572,7 +2681,7 @@ export class SubredditResources {
|
||||
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));
|
||||
propResultsMap.age!.passed = criteriaPassWithIncludeBehavior(ageTest, include);
|
||||
if (!propResultsMap.age!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2589,7 +2698,7 @@ export class SubredditResources {
|
||||
lkMatch = comparisonTextOp(item.author.link_karma, lkCompare.operator, lkCompare.value);
|
||||
}
|
||||
propResultsMap.linkKarma!.found = tk;
|
||||
propResultsMap.linkKarma!.passed = !((include && !lkMatch) || (!include && lkMatch));
|
||||
propResultsMap.linkKarma!.passed = criteriaPassWithIncludeBehavior(lkMatch, include);
|
||||
if (!propResultsMap.linkKarma!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2605,7 +2714,7 @@ export class SubredditResources {
|
||||
ckMatch = comparisonTextOp(item.author.comment_karma, ckCompare.operator, ckCompare.value);
|
||||
}
|
||||
propResultsMap.commentKarma!.found = ck;
|
||||
propResultsMap.commentKarma!.passed = !((include && !ckMatch) || (!include && ckMatch));
|
||||
propResultsMap.commentKarma!.passed = criteriaPassWithIncludeBehavior(ckMatch, include);
|
||||
if (!propResultsMap.commentKarma!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2619,7 +2728,7 @@ export class SubredditResources {
|
||||
}
|
||||
const tkMatch = comparisonTextOp(totalKarma, tkCompare.operator, tkCompare.value);
|
||||
propResultsMap.totalKarma!.found = totalKarma;
|
||||
propResultsMap.totalKarma!.passed = !((include && !tkMatch) || (!include && tkMatch));
|
||||
propResultsMap.totalKarma!.passed = criteriaPassWithIncludeBehavior(tkMatch, include);
|
||||
if (!propResultsMap.totalKarma!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2629,7 +2738,7 @@ export class SubredditResources {
|
||||
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));
|
||||
propResultsMap.verified!.passed = criteriaPassWithIncludeBehavior(vMatch, include);
|
||||
if (!propResultsMap.verified!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
@@ -2655,7 +2764,7 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
propResultsMap.description!.found = typeof desc === 'string' ? truncateStringToLength(50)(desc) : desc;
|
||||
propResultsMap.description!.passed = !((include && !passed) || (!include && passed));
|
||||
propResultsMap.description!.passed = criteriaPassWithIncludeBehavior(passed, include);
|
||||
if (!propResultsMap.description!.passed) {
|
||||
shouldContinue = false;
|
||||
} else {
|
||||
@@ -2672,8 +2781,10 @@ export class SubredditResources {
|
||||
value,
|
||||
operator,
|
||||
isPercent,
|
||||
duration,
|
||||
extra = ''
|
||||
} = parseGenericValueOrPercentComparison(count);
|
||||
const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration);
|
||||
const order = extra.includes('asc') ? 'ascending' : 'descending';
|
||||
switch (search) {
|
||||
case 'current':
|
||||
@@ -2688,29 +2799,32 @@ export class SubredditResources {
|
||||
}
|
||||
break;
|
||||
case 'consecutive':
|
||||
let orderedNotes = notes;
|
||||
if (isPercent) {
|
||||
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
|
||||
}
|
||||
|
||||
let orderedNotes = cutoffDate === undefined ? notes : notes.filter(x => x.time.isSameOrAfter(cutoffDate));
|
||||
if (order === 'descending') {
|
||||
orderedNotes = [...notes];
|
||||
orderedNotes.reverse();
|
||||
}
|
||||
let currCount = 0;
|
||||
let maxCount = 0;
|
||||
for (const note of orderedNotes) {
|
||||
if (note.noteType === type) {
|
||||
currCount++;
|
||||
maxCount = Math.max(maxCount, 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;
|
||||
}
|
||||
}
|
||||
foundNoteResult.push(`Found ${currCount} ${type} consecutively`);
|
||||
if (comparisonTextOp(currCount, operator, value)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 'total':
|
||||
const filteredNotes = notes.filter(x => x.noteType === type);
|
||||
const filteredNotes = notes.filter(x => x.noteType === type && cutoffDate === undefined || (x.time.isSameOrAfter(cutoffDate)));
|
||||
if (isPercent) {
|
||||
// avoid divide by zero
|
||||
const percent = notes.length === 0 ? 0 : filteredNotes.length / notes.length;
|
||||
@@ -2731,11 +2845,222 @@ export class SubredditResources {
|
||||
}
|
||||
const noteResult = notePass();
|
||||
propResultsMap.userNotes!.found = foundNoteResult.join(' | ');
|
||||
propResultsMap.userNotes!.passed = !((include && !noteResult) || (!include && noteResult));
|
||||
propResultsMap.userNotes!.passed = criteriaPassWithIncludeBehavior(noteResult, include);
|
||||
if (!propResultsMap.userNotes!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
break;
|
||||
case 'modActions':
|
||||
const modActions = await this.getAuthorModNotesByActivityAuthor(item);
|
||||
// TODO convert these prior to running filter so we don't have to do it every time
|
||||
const actionCriterias = authorOptVal as (ModNoteCriteria | ModLogCriteria)[];
|
||||
let actionResult: string[] = [];
|
||||
|
||||
const actionsPass = () => {
|
||||
|
||||
for (const actionCriteria of actionCriterias) {
|
||||
|
||||
const {search = 'current', count = '>= 1'} = actionCriteria;
|
||||
|
||||
|
||||
const {
|
||||
value,
|
||||
operator,
|
||||
isPercent,
|
||||
duration,
|
||||
extra = ''
|
||||
} = parseGenericValueOrPercentComparison(count);
|
||||
const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration);
|
||||
|
||||
let actionsToUse: ModNote[] = [];
|
||||
if(asModNoteCriteria(actionCriteria)) {
|
||||
actionsToUse = actionsToUse.filter(x => x.type === 'NOTE');
|
||||
} else {
|
||||
actionsToUse = modActions;
|
||||
}
|
||||
|
||||
if(search === 'current' && actionsToUse.length > 0) {
|
||||
actionsToUse = [actionsToUse[0]];
|
||||
}
|
||||
|
||||
let validActions: ModNote[] = [];
|
||||
if (asModLogCriteria(actionCriteria)) {
|
||||
const fullCrit = toFullModLogCriteria(actionCriteria);
|
||||
const fullCritEntries = Object.entries(fullCrit);
|
||||
validActions = actionsToUse.filter(x => {
|
||||
|
||||
// filter out any notes that occur before time range
|
||||
if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [k, v] of fullCritEntries) {
|
||||
const key = k.toLocaleLowerCase();
|
||||
if (['count', 'search'].includes(key)) {
|
||||
continue;
|
||||
}
|
||||
switch (key) {
|
||||
case 'type':
|
||||
if (!v.includes((x.type as ModActionType))) {
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'activitytype':
|
||||
const anyMatch = v.some((a: ActivityType) => {
|
||||
switch (a) {
|
||||
case 'submission':
|
||||
if (x.action.actedOn instanceof Submission) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 'comment':
|
||||
if (x.action.actedOn instanceof Comment) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (!anyMatch) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'description':
|
||||
case 'action':
|
||||
case 'details':
|
||||
const actionPropVal = x.action[key] as string;
|
||||
if (actionPropVal === undefined) {
|
||||
return false;
|
||||
}
|
||||
const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal));
|
||||
if (!anyPropMatch) {
|
||||
return false;
|
||||
}
|
||||
} // case end
|
||||
|
||||
} // for each end
|
||||
|
||||
return true;
|
||||
}); // filter end
|
||||
} else if(asModNoteCriteria(actionCriteria)) {
|
||||
const fullCrit = toFullModNoteCriteria(actionCriteria as ModNoteCriteria);
|
||||
const fullCritEntries = Object.entries(fullCrit);
|
||||
validActions = actionsToUse.filter(x => {
|
||||
|
||||
// filter out any notes that occur before time range
|
||||
if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [k, v] of fullCritEntries) {
|
||||
const key = k.toLocaleLowerCase();
|
||||
if (['count', 'search'].includes(key)) {
|
||||
continue;
|
||||
}
|
||||
switch (key) {
|
||||
case 'notetype':
|
||||
if (!v.map((x: ModUserNoteLabel) => x.toUpperCase()).includes((x.note.label as ModUserNoteLabel))) {
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'note':
|
||||
const actionPropVal = x.note.note;
|
||||
if (actionPropVal === undefined) {
|
||||
return false;
|
||||
}
|
||||
const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal));
|
||||
if (!anyPropMatch) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case 'activitytype':
|
||||
const anyMatch = v.some((a: ActivityType) => {
|
||||
switch (a) {
|
||||
case 'submission':
|
||||
if (x.action.actedOn instanceof Submission) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 'comment':
|
||||
if (x.action.actedOn instanceof Comment) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
if (!anyMatch) {
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
} // case end
|
||||
|
||||
} // for each end
|
||||
|
||||
return true;
|
||||
}); // filter end
|
||||
} else {
|
||||
throw new SimpleError(`Could not determine if a modActions criteria was for Mod Log or Mod Note. Given: ${JSON.stringify(actionCriteria)}`);
|
||||
}
|
||||
|
||||
switch (search) {
|
||||
case 'current':
|
||||
if (validActions.length === 0) {
|
||||
actionResult.push('No Mod Actions present');
|
||||
} else {
|
||||
actionResult.push('Current Action matches criteria');
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 'consecutive':
|
||||
if (isPercent) {
|
||||
throw new SimpleError(`When comparing Mod Actions with 'search: consecutive' the 'count' value cannot be a percentage. Given: ${count}`);
|
||||
}
|
||||
const validActionIds = validActions.map(x => x.id);
|
||||
const order = extra.includes('asc') ? 'ascending' : 'descending';
|
||||
let orderedActions = actionsToUse;
|
||||
if(order === 'descending') {
|
||||
orderedActions = [...actionsToUse];
|
||||
orderedActions.reverse();
|
||||
}
|
||||
let currCount = 0;
|
||||
let maxCount = 0;
|
||||
for(const action of orderedActions) {
|
||||
if(validActionIds.includes(action.id)) {
|
||||
currCount++;
|
||||
maxCount = Math.max(maxCount, currCount);
|
||||
} else {
|
||||
currCount = 0;
|
||||
}
|
||||
}
|
||||
actionResult.push(`Found maximum of ${maxCount} consecutive Mod Actions that matched criteria`);
|
||||
if (comparisonTextOp(currCount, operator, value)) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
case 'total':
|
||||
if (isPercent) {
|
||||
// avoid divide by zero
|
||||
const percent = notes.length === 0 ? 0 : validActions.length / actionsToUse.length;
|
||||
actionResult.push(`${formatNumber(percent)}% of ${actionsToUse.length} matched criteria`);
|
||||
if (comparisonTextOp(percent, operator, value / 100)) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
actionResult.push(`${validActions.length} matched criteria`);
|
||||
if (comparisonTextOp(validActions.length, operator, value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
} // criteria for loop ends
|
||||
return false;
|
||||
}
|
||||
const actionsResult = actionsPass();
|
||||
propResultsMap.modActions!.found = actionResult.join(' | ');
|
||||
propResultsMap.modActions!.passed = criteriaPassWithIncludeBehavior(actionsResult, include);
|
||||
if (!propResultsMap.modActions!.passed) {
|
||||
shouldContinue = false;
|
||||
}
|
||||
break;
|
||||
}
|
||||
//}
|
||||
if (!shouldContinue) {
|
||||
@@ -2744,12 +3069,11 @@ export class SubredditResources {
|
||||
}
|
||||
} 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.', {isSerious: false});
|
||||
throw new SimpleError('Reddit returned a 404 while trying to retrieve User profile. It is likely this user is shadowbanned.', {isSerious: false, code: 404});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// gather values and determine overall passed
|
||||
const propResults = Object.values(propResultsMap);
|
||||
@@ -2909,6 +3233,7 @@ export class BotResourcesManager {
|
||||
submissionTTL,
|
||||
subredditTTL,
|
||||
filterCriteriaTTL,
|
||||
modNotesTTL,
|
||||
selfTTL,
|
||||
provider,
|
||||
actionedEventsMax,
|
||||
@@ -2931,7 +3256,7 @@ export class BotResourcesManager {
|
||||
this.defaultCacheConfig = caching;
|
||||
this.defaultThirdPartyCredentials = thirdParty;
|
||||
this.defaultDatabase = database;
|
||||
this.ttlDefaults = {authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL, subredditTTL, selfTTL};
|
||||
this.ttlDefaults = {authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL, subredditTTL, selfTTL, modNotesTTL};
|
||||
this.botName = name as string;
|
||||
this.logger = logger;
|
||||
this.invokeeRepo = this.defaultDatabase.getRepository(InvokeeType);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import Snoowrap, {Listing} from "snoowrap";
|
||||
import {Subreddit} from "snoowrap/dist/objects";
|
||||
import Snoowrap, {Listing, RedditUser} from "snoowrap";
|
||||
import {Submission, Subreddit, Comment} from "snoowrap/dist/objects";
|
||||
import {parseSubredditName} from "../util";
|
||||
import {ModUserNoteLabel} from "../Common/Infrastructure/Atomic";
|
||||
import {CreateModNoteData, ModNote, ModNoteRaw, ModNoteSnoowrapPopulated} from "../Subreddit/ModNotes/ModNote";
|
||||
import {SimpleError} from "./Errors";
|
||||
|
||||
// const proxyFactory = (endpoint: string) => {
|
||||
// return class ProxiedSnoowrap extends Snoowrap {
|
||||
@@ -14,6 +17,26 @@ import {parseSubredditName} from "../util";
|
||||
// }
|
||||
// }
|
||||
|
||||
export interface ModNoteGetOptions {
|
||||
before?: string,
|
||||
filter?: ModUserNoteLabel,
|
||||
limit?: number
|
||||
}
|
||||
|
||||
export interface ModNotesRaw {
|
||||
mod_notes: ModNoteSnoowrapPopulated[]
|
||||
start_cursor: string
|
||||
end_cursor: string
|
||||
has_next_page: boolean
|
||||
}
|
||||
|
||||
export interface ModNotesResponse {
|
||||
notes: ModNote[]
|
||||
startCursor: string
|
||||
endCursor: string
|
||||
isFinished: boolean
|
||||
}
|
||||
|
||||
export class ExtendedSnoowrap extends Snoowrap {
|
||||
|
||||
constructor(args: any) {
|
||||
@@ -53,6 +76,70 @@ export class ExtendedSnoowrap extends Snoowrap {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getModNotes(subreddit: Subreddit | string, user: RedditUser | string, options: ModNoteGetOptions = {limit: 100}): Promise<ModNotesResponse> {
|
||||
|
||||
const authorName = typeof user === 'string' ? user : user.name;
|
||||
if(authorName === '[deleted]') {
|
||||
throw new SimpleError(`User is '[deleted]', cannot retrieve`, {isSerious: false});
|
||||
}
|
||||
const subredditName = typeof subreddit === 'string' ? subreddit : subreddit.display_name;
|
||||
|
||||
const data: any = {
|
||||
subreddit: subredditName,
|
||||
user: authorName,
|
||||
...options
|
||||
};
|
||||
const response = await this.oauthRequest({
|
||||
uri: `/api/mod/notes`,
|
||||
method: 'get',
|
||||
qs: data
|
||||
}) as ModNotesRaw;
|
||||
|
||||
// TODO get all mod notes (iterate pages if has_next_page)
|
||||
return {
|
||||
|
||||
// "undo" the _populate function snoowrap uses to replace user/subreddit keys with Proxies
|
||||
// because we want to store the "raw" response data when caching (where user/subreddit keys are strings) so we can construct ModNote from either api response or cache using same data
|
||||
notes: response.mod_notes.map(x => {
|
||||
return new ModNote({
|
||||
...x,
|
||||
subreddit: x.subreddit.display_name,
|
||||
user: x.user.name,
|
||||
}, this);
|
||||
|
||||
}),
|
||||
startCursor: response.start_cursor,
|
||||
endCursor: response.end_cursor,
|
||||
isFinished: !response.has_next_page
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Mod Note
|
||||
*
|
||||
* @see https://www.reddit.com/dev/api#POST_api_mod_notes
|
||||
* */
|
||||
async addModNote(data: CreateModNoteData): Promise<ModNote> {
|
||||
const {note, label} = data;
|
||||
|
||||
const requestData: any = {
|
||||
note,
|
||||
label,
|
||||
subreddit: data.subreddit.display_name,
|
||||
user: data.user.name,
|
||||
}
|
||||
if(data.activity !== undefined) {
|
||||
requestData.reddit_id = data.activity.id;
|
||||
}
|
||||
|
||||
const response =await this.oauthRequest({
|
||||
uri: `/api/mod/notes`,
|
||||
method: 'post',
|
||||
form: requestData
|
||||
}) as { created: ModNoteRaw };
|
||||
return new ModNote(response.created, this);
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestTrackingSnoowrap extends ExtendedSnoowrap {
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
convertSubredditsRawToStrong,
|
||||
getActivityAuthorName,
|
||||
getActivitySubredditName,
|
||||
isStrongSubredditState,
|
||||
isStrongSubredditState, isSubmission,
|
||||
mergeArr,
|
||||
normalizeName,
|
||||
parseDurationValToDuration,
|
||||
@@ -344,7 +344,7 @@ export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain =
|
||||
|
||||
export const activityIsRemoved = (item: Submission | Comment): boolean => {
|
||||
if(item.can_mod_post) {
|
||||
if (item instanceof Submission) {
|
||||
if (asSubmission(item)) {
|
||||
// when automod filters a post it gets this category
|
||||
return item.banned_at_utc !== null && item.removed_by_category !== 'automod_filtered';
|
||||
}
|
||||
@@ -352,7 +352,7 @@ export const activityIsRemoved = (item: Submission | Comment): boolean => {
|
||||
// so if we want to processing filtered comments we need to check for this
|
||||
return item.banned_at_utc !== null && item.removed;
|
||||
} else {
|
||||
if (item instanceof Submission) {
|
||||
if (asSubmission(item)) {
|
||||
return item.removed_by_category === 'moderator' || item.removed_by_category === 'deleted';
|
||||
}
|
||||
// in subreddits the bot does not mod it is not possible to tell the difference between a comment that was removed by the user and one that was removed by a mod
|
||||
@@ -362,7 +362,7 @@ export const activityIsRemoved = (item: Submission | Comment): boolean => {
|
||||
|
||||
export const activityIsFiltered = (item: Submission | Comment): boolean => {
|
||||
if(item.can_mod_post) {
|
||||
if (item instanceof Submission) {
|
||||
if (asSubmission(item)) {
|
||||
// when automod filters a post it gets this category
|
||||
return item.banned_at_utc !== null && item.removed_by_category === 'automod_filtered';
|
||||
}
|
||||
@@ -375,7 +375,7 @@ export const activityIsFiltered = (item: Submission | Comment): boolean => {
|
||||
}
|
||||
|
||||
export const activityIsDeleted = (item: Submission | Comment): boolean => {
|
||||
if (item instanceof Submission) {
|
||||
if (asSubmission(item)) {
|
||||
return item.removed_by_category === 'deleted';
|
||||
}
|
||||
return item.author.name === '[deleted]'
|
||||
|
||||
@@ -82,13 +82,26 @@ app.use((req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
const staticHeaders = (res: express.Response, path: string, stat: object) => {
|
||||
res.setHeader('X-Robots-Tag', 'noindex');
|
||||
}
|
||||
const staticOpts = {
|
||||
setHeaders: staticHeaders
|
||||
}
|
||||
|
||||
app.use(bodyParser.urlencoded({extended: false}));
|
||||
//app.use(cookieParser());
|
||||
app.set('views', `${__dirname}/../assets/views`);
|
||||
app.set('view engine', 'ejs');
|
||||
app.use('/public', express.static(`${__dirname}/../assets/public`));
|
||||
app.use('/monaco', express.static(`${__dirname}/../../../node_modules/monaco-editor/`));
|
||||
app.use('/schemas', express.static(`${__dirname}/../../Schema/`));
|
||||
app.use('/public', express.static(`${__dirname}/../assets/public`, staticOpts));
|
||||
app.use('/monaco', express.static(`${__dirname}/../../../node_modules/monaco-editor/`, staticOpts));
|
||||
app.use('/schemas', express.static(`${__dirname}/../../Schema/`, staticOpts));
|
||||
|
||||
app.use((req, res, next) => {
|
||||
// https://developers.google.com/search/docs/advanced/crawling/block-indexing#http-response-header
|
||||
res.setHeader('X-Robots-Tag', 'noindex');
|
||||
next();
|
||||
});
|
||||
|
||||
const userAgent = `web:contextBot:web`;
|
||||
|
||||
@@ -891,7 +904,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
|
||||
app.postAsync('/config', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(true)], async (req: express.Request, res: express.Response) => {
|
||||
const {subreddit} = req.query as any;
|
||||
const {location, data, create = false} = req.body as any;
|
||||
const {location, data, reason = 'Updated through CM Web', create = false} = req.body as any;
|
||||
|
||||
const client = new ExtendedSnoowrap({
|
||||
userAgent,
|
||||
@@ -905,7 +918,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
const wiki = await client.getSubreddit(subreddit).getWikiPage(location);
|
||||
await wiki.edit({
|
||||
text: data,
|
||||
reason: create ? 'Created Config through CM Web' : 'Updated through CM Web'
|
||||
reason
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500);
|
||||
|
||||
@@ -31,13 +31,13 @@ const addBot = () => {
|
||||
const {
|
||||
bots: botsFromConfig = []
|
||||
} = req.botApp.fileConfig.document.toJS();
|
||||
if(botsFromConfig.length === 0 || botsFromConfig.some(x => x.name !== botData.name)) {
|
||||
if (botsFromConfig.length === 0 || !botsFromConfig.some(x => x.name === botData.name)) {
|
||||
req.botApp.logger.warn('Overwriting existing bot with the same name BUT this bot does not exist in the operator CONFIG FILE. You should check how you have provided config before next start or else this bot may be started twice (once from file, once from arg/env)');
|
||||
|
||||
}
|
||||
|
||||
await existingBot.destroy('system');
|
||||
req.botApp.bots.filter(x => x.botAccount !== botData.name);
|
||||
const existingBotIndex = req.botApp.bots.findIndex(x => x.botAccount === botData.name);
|
||||
req.botApp.bots.splice(existingBotIndex, 1);
|
||||
}
|
||||
|
||||
req.botApp.fileConfig.document.addBot(botData);
|
||||
@@ -55,13 +55,8 @@ const addBot = () => {
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
await newBot.testClient();
|
||||
await newBot.buildManagers();
|
||||
newBot.runManagers('system').catch((err) => {
|
||||
req.botApp.logger.error(`Unexpected error occurred while running Bot ${newBot.botName}. Bot must be re-built to restart`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
req.botApp.logger.error(err);
|
||||
}
|
||||
});
|
||||
// return response early so client doesn't have to wait for all managers to be built
|
||||
res.json(result);
|
||||
} catch (err: any) {
|
||||
result.success = false;
|
||||
if (newBot.error === undefined) {
|
||||
@@ -73,7 +68,22 @@ const addBot = () => {
|
||||
req.botApp.logger.error(err);
|
||||
}
|
||||
}
|
||||
return res.json(result);
|
||||
|
||||
try {
|
||||
await newBot.buildManagers();
|
||||
newBot.runManagers('system').catch((err) => {
|
||||
req.botApp.logger.error(`Unexpected error occurred while running Bot ${newBot.botName}. Bot must be re-built to restart`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
req.botApp.logger.error(err);
|
||||
}
|
||||
});
|
||||
} catch (err: any) {
|
||||
req.botApp.logger.error(`Bot ${newBot.botName} cannot recover from this error and must be re-built`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
req.botApp.logger.error(err);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
return [...middleware, response];
|
||||
}
|
||||
|
||||
@@ -41,6 +41,12 @@ const server = addAsync(express());
|
||||
server.use(bodyParser.json());
|
||||
server.use(bodyParser.urlencoded({extended: false}));
|
||||
|
||||
server.use((req, res, next) => {
|
||||
// https://developers.google.com/search/docs/advanced/crawling/block-indexing#http-response-header
|
||||
res.setHeader('X-Robots-Tag', 'noindex');
|
||||
next();
|
||||
});
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user: string,
|
||||
|
||||
@@ -57,7 +57,9 @@
|
||||
</span>
|
||||
</span>
|
||||
| <input id="configUrl" class="text-black placeholder-gray-500 rounded mx-2" style="min-width:400px;" placeholder="URL of a config to load"/> <a href="#" id="loadConfig">Load</a>
|
||||
<span id="saveTip">
|
||||
<div id="editWrapper" class="my-2">
|
||||
<label style="display: none" for="reason">Edit Reason</label><input id="reason" class="text-black placeholder-gray-500 rounded mr-2" style="min-width:400px;" placeholder="Edit Reason: Updated through CM Web"/>
|
||||
<span id="saveTip">
|
||||
<span style="margin-top:30px; z-index:100" class="tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 space-y-3 p-2 text-left">
|
||||
<div>In order to <strong id="configPageActionType">save</strong> a configuration to a subreddit's wiki page you must re-authorize ContextMod with Reddit to get the following permissions:</div>
|
||||
<ul class="list-inside list-disc" id="reauthPermissions">
|
||||
@@ -67,7 +69,7 @@
|
||||
<div><b><a href="#" id="doAuthorize">Click Here to re-authorize</a></b></div>
|
||||
</span>
|
||||
<span>
|
||||
| <a id="doSave">Save</a>
|
||||
<a id="doSave">Save</a>
|
||||
<svg id="saveQuestionIcon" xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
@@ -76,6 +78,7 @@
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="error" class="font-semibold"></div>
|
||||
<select style="display:none;" id="schema-selection">
|
||||
<option value="bot.yaml">Bot Configuration</option>
|
||||
@@ -138,12 +141,14 @@
|
||||
const saveLink = document.querySelector('#doSave');
|
||||
saveLink.classList.remove('isDisabled');
|
||||
saveLink.href = '#';
|
||||
document.querySelector('#reason').style.display = 'initial';
|
||||
} else {
|
||||
document.querySelector('#saveTip').classList.add('has-tooltip');
|
||||
document.querySelector('#saveQuestionIcon').style.display = 'initial';
|
||||
const saveLink = document.querySelector('#doSave');
|
||||
saveLink.classList.add('isDisabled');
|
||||
saveLink.href = '';
|
||||
document.querySelector('#reason').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,18 +201,24 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
location: window.wikiLocation,
|
||||
create: window.creatingWikiPage,
|
||||
data: window.ed.getModel().getValue(),
|
||||
};
|
||||
|
||||
const reasonVal = document.querySelector('#reason').value;
|
||||
if(reasonVal.trim() !== '') {
|
||||
payload.reason = reasonVal;
|
||||
}
|
||||
|
||||
fetch(`${document.location.origin}/config${document.location.search}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
//...data,
|
||||
location: window.wikiLocation,
|
||||
create: window.creatingWikiPage,
|
||||
data: window.ed.getModel().getValue()
|
||||
})
|
||||
body: JSON.stringify(payload)
|
||||
}).then((resp) => {
|
||||
if (!resp.ok) {
|
||||
resp.text().then(data => {
|
||||
@@ -217,6 +228,9 @@
|
||||
if(window.creatingWikiPage) {
|
||||
window.isCreatingWikiPage(false);
|
||||
}
|
||||
|
||||
document.querySelector('#reason').value = '';
|
||||
|
||||
document.querySelector('#error').innerHTML = `Wiki saved!`;
|
||||
window.dirty = false;
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -8,5 +8,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<!-- This is internal tool or at the very least a private site, should not be indexed -->
|
||||
<!-- https://developers.google.com/search/docs/advanced/crawling/block-indexing#meta-tag -->
|
||||
<meta name="robots" content="noindex">
|
||||
<!--icons from https://heroicons.com -->
|
||||
</head>
|
||||
|
||||
@@ -1139,7 +1139,7 @@
|
||||
|
||||
function getLiveStats(bot, sub) {
|
||||
console.debug(`Getting live stats for ${bot} ${sub}`)
|
||||
const fetchPromise = fetch(`/api/liveStats?instance=<%= instanceId %>&bot=${bot}&subreddit=${sub}`)
|
||||
return fetch(`/api/liveStats?instance=<%= instanceId %>&bot=${bot}&subreddit=${sub}`)
|
||||
.then(response => response.json())
|
||||
.then(resp => updateLiveStats(resp));
|
||||
}
|
||||
@@ -1197,10 +1197,17 @@
|
||||
}
|
||||
|
||||
// always get live stats for tab we just started viewing
|
||||
getLiveStats(bot, sub);
|
||||
const liveStatsInt = setInterval(() => getLiveStats(bot, sub), 5000);
|
||||
recentlySeen.set(identifier, {liveStatsInt});
|
||||
|
||||
getLiveStats(bot, sub).then(() => {
|
||||
let liveStatsInt;
|
||||
const liveStatFunc = () => {
|
||||
getLiveStats(bot, sub).catch(() => {
|
||||
// stop interval if live stat encounters an error
|
||||
clearInterval(liveStatsInt);
|
||||
})
|
||||
};
|
||||
liveStatsInt = setInterval(liveStatFunc, 5000);
|
||||
recentlySeen.set(identifier, {liveStatsInt});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
184
src/util.ts
184
src/util.ts
@@ -1,5 +1,5 @@
|
||||
import winston, {Logger} from "winston";
|
||||
import dayjs, {Dayjs, OpUnitType} from 'dayjs';
|
||||
import dayjs, {Dayjs} from 'dayjs';
|
||||
import {Duration} from 'dayjs/plugin/duration.js';
|
||||
import Ajv from "ajv";
|
||||
import {InvalidOptionArgumentError} from "commander";
|
||||
@@ -38,7 +38,7 @@ import redisStore from "cache-manager-redis-store";
|
||||
import Autolinker from 'autolinker';
|
||||
import {create as createMemoryStore} from './Utils/memoryStore';
|
||||
import {LEVEL, MESSAGE} from "triple-beam";
|
||||
import {Comment, RedditUser, Submission} from "snoowrap/dist/objects";
|
||||
import {Comment, PrivateMessage, RedditUser, Submission, Subreddit} from "snoowrap/dist/objects";
|
||||
import reRegExp from '@stdlib/regexp-regexp';
|
||||
import fetch from "node-fetch";
|
||||
import ImageData from "./Common/ImageData";
|
||||
@@ -54,10 +54,14 @@ import {RuleResultEntity as RuleResultEntity} from "./Common/Entities/RuleResult
|
||||
import {nanoid} from "nanoid";
|
||||
import {
|
||||
ActivityState,
|
||||
asModLogCriteria,
|
||||
asModNoteCriteria,
|
||||
AuthorCriteria,
|
||||
authorCriteriaProperties,
|
||||
CommentState,
|
||||
defaultStrongSubredditCriteriaOptions,
|
||||
ModLogCriteria,
|
||||
ModNoteCriteria,
|
||||
StrongSubredditCriteria,
|
||||
SubmissionState,
|
||||
SubredditCriteria,
|
||||
@@ -70,14 +74,14 @@ import {
|
||||
CacheProvider,
|
||||
ConfigFormat,
|
||||
DurationVal,
|
||||
ModUserNoteLabel,
|
||||
modUserNoteLabels,
|
||||
RedditEntity,
|
||||
RedditEntityType,
|
||||
statFrequencies,
|
||||
StatisticFrequency,
|
||||
StatisticFrequencyOption,
|
||||
StringOperator
|
||||
StatisticFrequencyOption
|
||||
} from "./Common/Infrastructure/Atomic";
|
||||
import {DurationComparison} from "./Common/Infrastructure/Comparisons";
|
||||
import {
|
||||
AuthorOptions,
|
||||
FilterCriteriaDefaults,
|
||||
@@ -109,6 +113,7 @@ import {
|
||||
HistoryFiltersOptions
|
||||
} from "./Common/Infrastructure/ActivityWindow";
|
||||
import {RunnableBaseJson} from "./Common/Infrastructure/Runnable";
|
||||
import Snoowrap from "snoowrap";
|
||||
|
||||
|
||||
//import {ResembleSingleCallbackComparisonResult} from "resemblejs";
|
||||
@@ -550,6 +555,8 @@ export const filterCriteriaPropertySummary = <T>(val: FilterCriteriaPropertyResu
|
||||
const expectedStrings = crit.map((x: any) => {
|
||||
if (asUserNoteCriteria(x)) {
|
||||
return userNoteCriteriaSummary(x);
|
||||
} else if(asModNoteCriteria(x) || asModLogCriteria(x)) {
|
||||
return modActionCriteriaSummary(x);
|
||||
}
|
||||
return x;
|
||||
}).join(' OR ');
|
||||
@@ -708,40 +715,16 @@ export const isActivityWindowConfig = (val: any): val is FullActivityWindowConfi
|
||||
return false;
|
||||
}
|
||||
|
||||
export const comparisonTextOp = (val1: number, strOp: string, val2: number): boolean => {
|
||||
switch (strOp) {
|
||||
case '>':
|
||||
return val1 > val2;
|
||||
case '>=':
|
||||
return val1 >= val2;
|
||||
case '<':
|
||||
return val1 < val2;
|
||||
case '<=':
|
||||
return val1 <= val2;
|
||||
default:
|
||||
throw new Error(`${strOp} was not a recognized operator`);
|
||||
}
|
||||
}
|
||||
|
||||
export const dateComparisonTextOp = (val1: Dayjs, strOp: StringOperator, val2: Dayjs, granularity?: OpUnitType): boolean => {
|
||||
switch (strOp) {
|
||||
case '>':
|
||||
return val1.isBefore(val2, granularity);
|
||||
case '>=':
|
||||
return val1.isSameOrBefore(val2, granularity);
|
||||
case '<':
|
||||
return val1.isAfter(val2, granularity);
|
||||
case '<=':
|
||||
return val1.isSameOrAfter(val2, granularity);
|
||||
default:
|
||||
throw new Error(`${strOp} was not a recognized operator`);
|
||||
}
|
||||
}
|
||||
|
||||
// string must only contain ISO8601 optionally wrapped by whitespace
|
||||
const ISO8601_REGEX: RegExp = /^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
||||
// finds ISO8601 in any part of a string
|
||||
const ISO8601_SUBSTRING_REGEX: RegExp = /(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?/;
|
||||
// string must only duration optionally wrapped by whitespace
|
||||
const DURATION_REGEX: RegExp = /^\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$/;
|
||||
export const parseDuration = (val: string): Duration => {
|
||||
let matches = val.match(DURATION_REGEX);
|
||||
// finds duration in any part of the string
|
||||
const DURATION_SUBSTRING_REGEX: RegExp = /(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)/;
|
||||
export const parseDuration = (val: string, strict = true): Duration => {
|
||||
let matches = val.match(strict ? DURATION_REGEX : DURATION_SUBSTRING_REGEX);
|
||||
if (matches !== null) {
|
||||
const groups = matches.groups as any;
|
||||
const dur: Duration = dayjs.duration(groups.time, groups.unit);
|
||||
@@ -750,7 +733,7 @@ export const parseDuration = (val: string): Duration => {
|
||||
}
|
||||
return dur;
|
||||
}
|
||||
matches = val.match(ISO8601_REGEX);
|
||||
matches = val.match(strict ? ISO8601_REGEX : ISO8601_SUBSTRING_REGEX);
|
||||
if (matches !== null) {
|
||||
const dur: Duration = dayjs.duration(val);
|
||||
if (!dayjs.isDuration(dur)) {
|
||||
@@ -758,32 +741,7 @@ export const parseDuration = (val: string): Duration => {
|
||||
}
|
||||
return dur;
|
||||
}
|
||||
throw new InvalidRegexError([DURATION_REGEX, ISO8601_REGEX], val)
|
||||
}
|
||||
|
||||
/**
|
||||
* Named groups: operator, time, unit
|
||||
* */
|
||||
const DURATION_COMPARISON_REGEX: RegExp = /^\s*(?<opStr>>|>=|<|<=)\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$/;
|
||||
const DURATION_COMPARISON_REGEX_URL = 'https://regexr.com/609n8';
|
||||
export const parseDurationComparison = (val: string): DurationComparison => {
|
||||
const matches = val.match(DURATION_COMPARISON_REGEX);
|
||||
if (matches === null) {
|
||||
throw new InvalidRegexError(DURATION_COMPARISON_REGEX, val, DURATION_COMPARISON_REGEX_URL)
|
||||
}
|
||||
const groups = matches.groups as any;
|
||||
const dur: Duration = dayjs.duration(groups.time, groups.unit);
|
||||
if (!dayjs.isDuration(dur)) {
|
||||
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
|
||||
}
|
||||
return {
|
||||
operator: groups.opStr as StringOperator,
|
||||
duration: dur
|
||||
}
|
||||
}
|
||||
export const compareDurationValue = (comp: DurationComparison, date: Dayjs) => {
|
||||
const dateToCompare = dayjs().subtract(comp.duration.asSeconds(), 'seconds');
|
||||
return dateComparisonTextOp(date, comp.operator, dateToCompare);
|
||||
throw new InvalidRegexError([(strict ? DURATION_REGEX : DURATION_SUBSTRING_REGEX), (strict ? ISO8601_REGEX : ISO8601_SUBSTRING_REGEX)], val)
|
||||
}
|
||||
|
||||
const SUBREDDIT_NAME_REGEX: RegExp = /^\s*(?:\/r\/|r\/)*(\w+)*\s*$/;
|
||||
@@ -1412,6 +1370,18 @@ export const parseStringToRegex = (val: string, defaultFlags?: string): RegExp |
|
||||
return new RegExp(result[1], flags);
|
||||
}
|
||||
|
||||
export const parseStringToRegexOrLiteralSearch = (val: string, defaultFlags: string = 'i'): RegExp => {
|
||||
const maybeRegex = parseStringToRegex(val, defaultFlags);
|
||||
if (maybeRegex !== undefined) {
|
||||
return maybeRegex;
|
||||
}
|
||||
const literalSearchRegex = parseStringToRegex(`/${escapeRegex(val.trim())}/`, defaultFlags);
|
||||
if (literalSearchRegex === undefined) {
|
||||
throw new SimpleError(`Could not convert test value to a valid regex: ${val}`);
|
||||
}
|
||||
return literalSearchRegex;
|
||||
}
|
||||
|
||||
export const parseRegex = (reg: RegExp, val: string): RegExResult => {
|
||||
|
||||
if(reg.global) {
|
||||
@@ -1729,12 +1699,25 @@ export const difference = (a: Array<any>, b: Array<any>) => {
|
||||
return Array.from(setMinus(a, b));
|
||||
}
|
||||
|
||||
// can use 'in' operator to check if object has a property with name WITHOUT TRIGGERING a snoowrap proxy to fetch
|
||||
export const isSubreddit = (value: any) => {
|
||||
try {
|
||||
return value !== null && typeof value === 'object' && (value instanceof Subreddit || ('id' in value && value.id !== undefined && value.id.includes('t5_')) || 'display_name' in value);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const asSubreddit = (value: any): value is Subreddit => {
|
||||
return isSubreddit(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached activities lose type information when deserialized so need to check properties as well to see if the object is the shape of a Submission
|
||||
* */
|
||||
export const isSubmission = (value: any) => {
|
||||
try {
|
||||
return value !== null && typeof value === 'object' && (value instanceof Submission || (value.name !== undefined && value.name.includes('t3_')));
|
||||
return value !== null && typeof value === 'object' && (value instanceof Submission || ('name' in value && value.name !== undefined && value.name.includes('t3_')));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -1746,7 +1729,7 @@ export const asSubmission = (value: any): value is Submission => {
|
||||
|
||||
export const isComment = (value: any) => {
|
||||
try {
|
||||
return value !== null && typeof value === 'object' && (value instanceof Comment || value.name.includes('t1_'));
|
||||
return value !== null && typeof value === 'object' && (value instanceof Comment || ('name' in value && value.name !== undefined && value.name.includes('t1_')));
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
@@ -1762,7 +1745,7 @@ export const asActivity = (value: any): value is (Submission | Comment) => {
|
||||
|
||||
export const isUser = (value: any) => {
|
||||
try {
|
||||
return value !== null && typeof value === 'object' && (value instanceof RedditUser || value.name.includes('t2_'));
|
||||
return value !== null && typeof value === 'object' && (value instanceof RedditUser || ('name' in value && value.name !== undefined && value.name.includes('t2_')));
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
@@ -1784,6 +1767,20 @@ export const userNoteCriteriaSummary = (val: UserNoteCriteria): string => {
|
||||
return `${val.count === undefined ? '>= 1' : val.count} of ${val.search === undefined ? 'current' : val.search} notes is ${val.type}`;
|
||||
}
|
||||
|
||||
export const modActionCriteriaSummary = (val: (ModNoteCriteria | ModLogCriteria)): string => {
|
||||
const isNote = asModNoteCriteria(val);
|
||||
const preamble = `${val.count === undefined ? '>= 1' : val.count} of ${val.search === undefined ? 'current' : val.search} ${isNote ? 'notes' : 'actions'} is`;
|
||||
const filters = Object.entries(val).reduce((acc: string[], curr) => {
|
||||
if(['count', 'search'].includes(curr[0])) {
|
||||
return acc;
|
||||
}
|
||||
const vals = Array.isArray(curr[1]) ? curr[1] : [curr[1]];
|
||||
acc.push(`${curr[0]}: ${vals.join(' ,')}`)
|
||||
return acc;
|
||||
}, []);
|
||||
return `${preamble} ${filters.join(' || ')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialized activities store subreddit and user properties as their string representations (instead of proxy)
|
||||
* */
|
||||
@@ -2452,6 +2449,30 @@ export const normalizeCriteria = <T extends AuthorCriteria | TypedActivityState
|
||||
if(criteria.description !== undefined) {
|
||||
criteria.description = Array.isArray(criteria.description) ? criteria.description : [criteria.description];
|
||||
}
|
||||
if(criteria.modActions !== undefined) {
|
||||
criteria.modActions.map((x, index) => {
|
||||
const common = {
|
||||
...x,
|
||||
type: x.type === undefined ? undefined : (Array.isArray(x.type) ? x.type : [x.type])
|
||||
}
|
||||
if(asModNoteCriteria(x)) {
|
||||
return {
|
||||
...common,
|
||||
noteType: x.noteType === undefined ? undefined : (Array.isArray(x.noteType) ? x.noteType : [x.noteType]),
|
||||
note: x.note === undefined ? undefined : (Array.isArray(x.note) ? x.note : [x.note]),
|
||||
}
|
||||
} else if(asModLogCriteria(x)) {
|
||||
return {
|
||||
...common,
|
||||
action: x.action === undefined ? undefined : (Array.isArray(x.action) ? x.action : [x.action]),
|
||||
details: x.details === undefined ? undefined : (Array.isArray(x.details) ? x.details : [x.details]),
|
||||
description: x.description === undefined ? undefined : (Array.isArray(x.description) ? x.description : [x.description]),
|
||||
activityType: x.activityType === undefined ? undefined : (Array.isArray(x.activityType) ? x.activityType : [x.activityType]),
|
||||
}
|
||||
}
|
||||
return common;
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -2615,6 +2636,22 @@ export const parseRedditFullname = (str: string): RedditThing | undefined => {
|
||||
}
|
||||
}
|
||||
|
||||
export const generateSnoowrapEntityFromRedditThing = (data: RedditThing, client: Snoowrap) => {
|
||||
switch(data.type) {
|
||||
case 'comment':
|
||||
return new Comment({id: data.val}, client, false);
|
||||
case 'submission':
|
||||
return new Submission({id: data.val}, client, false);
|
||||
case 'user':
|
||||
return new RedditUser({id: data.val}, client, false);
|
||||
case 'subreddit':
|
||||
return new Subreddit({id: data.val}, client, false);
|
||||
case 'message':
|
||||
return new PrivateMessage({id: data.val}, client, false)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export const activityDispatchConfigToDispatch = (config: ActivityDispatchConfig, activity: (Comment | Submission), type: ActivitySourceTypes, {action, dryRun}: {action?: string, dryRun?: boolean} = {}): ActivityDispatch => {
|
||||
let tolerantVal: boolean | Duration | undefined;
|
||||
if (config.tardyTolerant !== undefined) {
|
||||
@@ -2773,3 +2810,16 @@ export const between = (val: number, a: number, b: number, inclusiveMin: boolean
|
||||
// inclusive max
|
||||
return val > min && val <= max;
|
||||
}
|
||||
|
||||
export const toModNoteLabel = (val: string): ModUserNoteLabel => {
|
||||
const cleanVal = val.trim().toUpperCase();
|
||||
if (asModNoteLabel(cleanVal)) {
|
||||
return cleanVal;
|
||||
}
|
||||
throw new Error(`${val} is not a valid mod note label. Must be one of: ${modUserNoteLabels.join(', ')}`);
|
||||
}
|
||||
|
||||
|
||||
export const asModNoteLabel = (val: string): val is ModUserNoteLabel => {
|
||||
return modUserNoteLabels.includes(val);
|
||||
}
|
||||
|
||||
@@ -3,17 +3,19 @@ import {assert} from 'chai';
|
||||
import {
|
||||
COMMENT_URL_ID,
|
||||
parseDuration,
|
||||
parseDurationComparison,
|
||||
parseLinkIdentifier,
|
||||
parseRedditEntity, removeUndefinedKeys, SUBMISSION_URL_ID
|
||||
} from "../src/util";
|
||||
import dayjs from "dayjs";
|
||||
import dduration, {DurationUnitType} from 'dayjs/plugin/duration.js';
|
||||
import dduration, {Duration, DurationUnitType} from 'dayjs/plugin/duration.js';
|
||||
import {
|
||||
parseDurationComparison,
|
||||
parseGenericValueComparison,
|
||||
parseGenericValueOrPercentComparison
|
||||
} from "../src/Common/Infrastructure/Comparisons";
|
||||
|
||||
dayjs.extend(dduration);
|
||||
|
||||
|
||||
describe('Non-temporal Comparison Operations', function () {
|
||||
it('should throw if no operator sign', function () {
|
||||
@@ -54,12 +56,23 @@ describe('Non-temporal Comparison Operations', function () {
|
||||
const withoutPercent = parseGenericValueOrPercentComparison('<= 3');
|
||||
assert.isFalse(withoutPercent.isPercent)
|
||||
})
|
||||
it('should parse comparison with time component', function() {
|
||||
const val = parseGenericValueComparison('> 3 in 2 months');
|
||||
assert.equal(val.value, 3);
|
||||
assert.isFalse(val.isPercent);
|
||||
assert.exists(val.duration);
|
||||
assert.equal(dayjs.duration(2, 'months').milliseconds(), (val.duration as Duration).milliseconds());
|
||||
});
|
||||
it('should parse percentage comparison with time component', function() {
|
||||
const val = parseGenericValueOrPercentComparison('> 3% in 2 months');
|
||||
assert.equal(val.value, 3);
|
||||
assert.isTrue(val.isPercent);
|
||||
assert.exists(val.duration);
|
||||
assert.equal(dayjs.duration(2, 'months').milliseconds(), (val.duration as Duration).milliseconds());
|
||||
});
|
||||
});
|
||||
|
||||
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 () {
|
||||
|
||||
Reference in New Issue
Block a user