Compare commits
100 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74925fa8d8 | ||
|
|
43bfa3ca51 | ||
|
|
0127cbfd0f | ||
|
|
59d31bde84 | ||
|
|
17ae7fa295 | ||
|
|
bf074487ff | ||
|
|
85e786d248 | ||
|
|
641a7fbd63 | ||
|
|
0278a4d673 | ||
|
|
d318286507 | ||
|
|
36221705fa | ||
|
|
fdc0ccf4c8 | ||
|
|
a949a4ed10 | ||
|
|
d67283a923 | ||
|
|
21b2182ef0 | ||
|
|
fcfb037d6c | ||
|
|
8c3601a4cf | ||
|
|
cfc96b6c82 | ||
|
|
239d173ffd | ||
|
|
fda6090ddd | ||
|
|
d23f87ba60 | ||
|
|
2941386955 | ||
|
|
b2b924c01d | ||
|
|
ee20ba786b | ||
|
|
f71933b9b9 | ||
|
|
e6246188ad | ||
|
|
bfd5ba7816 | ||
|
|
fd5488376a | ||
|
|
6c8ea66fcc | ||
|
|
d02d70ded3 | ||
|
|
acbb9a8626 | ||
|
|
122d5fb2af | ||
|
|
cd8ccffa20 | ||
|
|
8695058064 | ||
|
|
98a8568eb6 | ||
|
|
457f947603 | ||
|
|
7fb69ae67a | ||
|
|
2241d40e49 | ||
|
|
a3ca3f17ec | ||
|
|
f527a17fa2 | ||
|
|
e98364eae9 | ||
|
|
8b125d7433 | ||
|
|
6ee060c5ce | ||
|
|
9b12d0b2b3 | ||
|
|
b174c7928a | ||
|
|
74dfe9258a | ||
|
|
1cf8855a24 | ||
|
|
adc69894fc | ||
|
|
3435c683c8 | ||
|
|
80f83bf84b | ||
|
|
f0032cd433 | ||
|
|
7933f77764 | ||
|
|
ade0b7948e | ||
|
|
542aa26c62 | ||
|
|
3faf4ca3dc | ||
|
|
2f35b82d5e | ||
|
|
3bcc3d78e8 | ||
|
|
c9d8bf637b | ||
|
|
027f4087e3 | ||
|
|
1b20122ffc | ||
|
|
5d53571ec0 | ||
|
|
b3df1b4d41 | ||
|
|
4abe8e07f3 | ||
|
|
9bb95106ba | ||
|
|
02414478bf | ||
|
|
8b0a582464 | ||
|
|
d1db5f4688 | ||
|
|
44f9389b69 | ||
|
|
71b2d0597d | ||
|
|
57cfcebe9f | ||
|
|
07ecc505ff | ||
|
|
81213686ce | ||
|
|
08735d505a | ||
|
|
1a62c752c1 | ||
|
|
6aa7367297 | ||
|
|
cf9583227c | ||
|
|
aa505ba3f2 | ||
|
|
a0182d89ca | ||
|
|
d46f0a5be8 | ||
|
|
4a55d35e14 | ||
|
|
1284051fe8 | ||
|
|
00680494a3 | ||
|
|
296f1c8dee | ||
|
|
77856a6d97 | ||
|
|
e32ac60db5 | ||
|
|
b216cd08e1 | ||
|
|
052c1218c6 | ||
|
|
c2343683bb | ||
|
|
fcf718f1d0 | ||
|
|
77f848007a | ||
|
|
95216b3950 | ||
|
|
58a21e8d05 | ||
|
|
49ac8cda19 | ||
|
|
bc8be3608b | ||
|
|
1b69cd78bb | ||
|
|
e736379f85 | ||
|
|
c0e1a93fb4 | ||
|
|
bd35b06ebf | ||
|
|
f852e85234 | ||
|
|
661ae11e18 |
@@ -13,7 +13,7 @@ coverage
|
||||
*.json5
|
||||
*.yaml
|
||||
*.yml
|
||||
|
||||
*.env
|
||||
|
||||
# exceptions
|
||||
!heroku.yml
|
||||
|
||||
1
.github/FUNDING.yml
vendored
@@ -1,2 +1,3 @@
|
||||
github: [FoxxMD]
|
||||
patreon: FoxxMD
|
||||
custom: ["bitcoincash:qqmpsh365r8n9jhp4p8ks7f7qdr7203cws4kmkmr8q"]
|
||||
|
||||
3
.github/push-hook-sample.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"ref": "refs/heads/edge"
|
||||
}
|
||||
@@ -1,4 +1,14 @@
|
||||
name: Publish Docker image to Dockerhub
|
||||
name: Publish Docker image to registries
|
||||
|
||||
# Builds image and tags based on the type of push event:
|
||||
# * branch push -> tag is branch name IE context-mod:edge
|
||||
# * release (tag) -> tag is release name IE context-mod:0.13.0
|
||||
#
|
||||
# Then pushes tagged images to multiple registries
|
||||
#
|
||||
# Based on
|
||||
# https://github.com/docker/build-push-action/blob/master/docs/advanced/push-multi-registries.md
|
||||
# https://github.com/docker/metadata-action
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -13,8 +23,12 @@ on:
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
name: Build and Push Docker image to registries
|
||||
runs-on: ubuntu-latest
|
||||
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v2
|
||||
@@ -25,12 +39,22 @@ jobs:
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: foxxmd/context-mod
|
||||
images: |
|
||||
foxxmd/context-mod
|
||||
ghcr.io/foxxmd/context-mod
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
|
||||
@@ -40,7 +64,8 @@ jobs:
|
||||
latest=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
if: ${{ !env.ACT }}
|
||||
uses: docker/build-push-action@v3
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
2
.gitignore
vendored
@@ -336,6 +336,7 @@ web_modules/
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
.env.test
|
||||
*.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
@@ -391,6 +392,7 @@ dist
|
||||
*.json5
|
||||
|
||||
!src/Schema/*.json
|
||||
!.github/push-hook-sample.json
|
||||
!docs/**/*.json5
|
||||
!docs/**/*.yaml
|
||||
!docs/**/*.json
|
||||
|
||||
@@ -31,6 +31,8 @@ Feature Highlights for **Moderators:**
|
||||
* [**Web interface**](#web-ui-and-screenshots) for monitoring, administration, and oauth bot authentication
|
||||
* [**Placeholders**](/docs/subreddit/actionTemplating.md) (like automoderator) can be configured via a wiki page or raw text and supports [mustache](https://mustache.github.io) templating
|
||||
* [**Partial Configurations**](/docs/subreddit/components/README.md#partial-configurations) -- offload parts of your configuration to shared locations to consolidate logic between multiple subreddits
|
||||
* [Guest Access](/docs/subreddit/README.md#guest-access) enables collaboration and easier setup by allowing temporary access
|
||||
* [Toxic content prediction](/docs/subreddit/components/README.md#moderatehatespeechcom-predictions) using [moderatehatespeech.com](https://moderatehatespeech.com) machine learning model
|
||||
|
||||
Feature highlights for **Developers and Hosting (Operators):**
|
||||
|
||||
@@ -134,6 +136,10 @@ Moderator view/invite and authorization:
|
||||
|
||||

|
||||
|
||||
A similar helper and invitation experience is available for adding **subreddits to an existing bot.**
|
||||
|
||||

|
||||
|
||||
### Configuration Editor
|
||||
|
||||
A built-in editor using [monaco-editor](https://microsoft.github.io/monaco-editor/) makes editing configurations easy:
|
||||
|
||||
3
act.env.example
Normal file
@@ -0,0 +1,3 @@
|
||||
GITHUB_TOKEN=
|
||||
DOCKERHUB_USERNAME=
|
||||
DOCKER_PASSWORD=
|
||||
@@ -152,7 +152,8 @@ An **Action** is some action the bot can take against the checked Activity (comm
|
||||
|
||||
* For **Operator/Bot maintainers** see **[Operation Guide](/docs/operator/README.md)**
|
||||
* For **Moderators**
|
||||
* Refer to the [Subreddit Components Documentation](/docs/subreddit/components) or the [subreddit-ready examples](/docs/subreddit/components/subredditReady)
|
||||
* Start with the [Subreddit/Moderator docs](/docs/subreddit/README.md) or [Moderator Getting Started guide](/docs/subreddit/gettingStarted.md)
|
||||
* Refer to the [Subreddit Components Documentation](/docs/subreddit/components) or the [subreddit-ready examples](/docs/subreddit/components/cookbook)
|
||||
* as well as the [schema](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) which has
|
||||
* fully annotated configuration data/structure
|
||||
* generated examples in json/yaml
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
TODO add more development sections...
|
||||
|
||||
# Developing/Testing Github Actions
|
||||
|
||||
Use [act](https://github.com/nektos/act) to run Github actions locally.
|
||||
|
||||
An example secrets file can be found in the project working directory at [act.env.example](act.env.example)
|
||||
|
||||
Modify [push-hook-sample.json](.github/push-hook-sample.json) to point to the local branch you want to run a `push` event trigger on, then run this command from the project working directory:
|
||||
|
||||
```bash
|
||||
act -e .github/push-hook-sample.json --secret-file act.env
|
||||
```
|
||||
|
||||
# Mocking Reddit API
|
||||
|
||||
Using [MockServer](https://www.mock-server.com/)
|
||||
|
||||
|
Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 133 KiB |
BIN
docs/images/guests.jpg
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 137 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 158 KiB |
BIN
docs/images/subredditInvite.jpg
Normal file
|
After Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 479 KiB After Width: | Height: | Size: 225 KiB |
@@ -95,7 +95,7 @@ The Retention Policy can be specified at operator level, bot, subreddit *overrid
|
||||
operator:
|
||||
name: u/MyRedditAccount
|
||||
databaseConfig:
|
||||
retention: '3 months' # each subreddit will retain 3 more of recorded events
|
||||
retention: '3 months' # each subreddit will retain 3 months of recorded events
|
||||
bots:
|
||||
# all subreddits this bot moderates will have 3 month retention
|
||||
- name: u/OneBotAccount
|
||||
|
||||
@@ -8,7 +8,10 @@ ContextMod can be run on almost any operating system but it is recommended to us
|
||||
|
||||
PROTIP: Using a container management tool like [Portainer.io CE](https://www.portainer.io/products/community-edition) will help with setup/configuration tremendously.
|
||||
|
||||
### [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod)
|
||||
Images available from these registeries:
|
||||
|
||||
* [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod) - `docker.io/foxxmd/context-mod`
|
||||
* [GHCR](https://github.com/foxxmd/context-mod/pkgs/container/context-mod) - `ghcr.io/foxxmd/context-mod`
|
||||
|
||||
An example of starting the container using the [minimum configuration](/docs/operator/configuration.md#minimum-config):
|
||||
|
||||
@@ -17,7 +20,7 @@ An example of starting the container using the [minimum configuration](/docs/ope
|
||||
* Expose the web interface using the container port `8085`
|
||||
|
||||
```
|
||||
docker run -d -v /host/path/folder:/config -p 8085:8085 foxxmd/context-mod
|
||||
docker run -d -v /host/path/folder:/config -p 8085:8085 ghcr.io/foxxmd/context-mod:latest
|
||||
```
|
||||
|
||||
The location of `DATA_DIR` in the container can be changed by passing it as an environmental variable EX `-e "DATA_DIR=/home/abc/config`
|
||||
@@ -34,7 +37,7 @@ To get the UID and GID for the current user run these commands from a terminal:
|
||||
* `id -g` -- prints GID
|
||||
|
||||
```
|
||||
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 foxxmd/context-mod
|
||||
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 ghcr.io/foxxmd/context-mod:latest
|
||||
```
|
||||
|
||||
## Locally
|
||||
|
||||
95
docs/subreddit/README.md
Normal file
@@ -0,0 +1,95 @@
|
||||
This section is for **reddit moderators**. It covers how to use a CM bot for your subreddit.
|
||||
|
||||
If you are trying to run a ContextMod instance (the actual software) please refer to the [operator section](/docs/operator/README.md).
|
||||
|
||||
# Table of Contents
|
||||
|
||||
* [Overview](#overview)
|
||||
* [Your Relationship to CM](#your-relationship-to-cm)
|
||||
* [Operator](#operator)
|
||||
* [Your Bot](#your-bot)
|
||||
* [Getting Started](#getting-started)
|
||||
* [Accessing The Bot](#accessing-the-bot)
|
||||
* [Editing The Bot](#editing-the-bot)
|
||||
* [Configuration](#configuration)
|
||||
* [Guest Access](#guest-access)
|
||||
|
||||
# Overview
|
||||
|
||||
The Context Mod **software** can manage multiple **bots** (reddit accounts used as bots, like `/u/MyCMBot`). Each bot can manage (run) multiple **subreddits** which is determined by the subreddits the account is a moderator of.
|
||||
|
||||
You, the moderator of a subreddit a CM bot runs in, can access/manage the Bot using the CM software's [web interface](/docs/images/subredditStatus.jpg) and control its behavior using the [web editor.](/docs/images/editor.jpg)
|
||||
|
||||
## Your Relationship to CM
|
||||
|
||||
It is important to understand the relationship between you (the moderator), the bot, and the operator (the person running the CM software).
|
||||
|
||||
The easiest way to think about this is in relation to how you use Automoderator and interact with Reddit as a moderator. As an analogy:
|
||||
|
||||
### Operator
|
||||
|
||||
The operator is the person running the actual server/machine the Context Mod software is on.
|
||||
|
||||
They are best thought of as **Reddit:**
|
||||
|
||||
* Mostly hands-off when it comes to the bot and interacting with your subreddit
|
||||
* You must interact with Reddit first before you can use automoderator (login, create a subreddit, etc...)
|
||||
|
||||
Unlike reddit, though, there is a greater level of trust required between you and the Operator because what you make the Bot do ultimately affects the Operator since they are the ones actually running your Bot and making API calls to reddit.
|
||||
|
||||
### Your Bot
|
||||
|
||||
Your bot is like an **invite-only version of Automoderator**:
|
||||
|
||||
* Unlike automoderator, you **must** interact with the Operator in order to get the bot working. It is not public for anyone to use.
|
||||
* Like automoderator, you **must** create a [configuration](/docs/subreddit/components/README.md) for it do anything.
|
||||
* The bot does not come pre-configured for you. It is a blank slate and requires user input to be useful.
|
||||
* Also like automoderator, you are **entirely in control of the bot.**
|
||||
* You can start, stop, and edit its behavior at any time without needing to communicate with the Operator.
|
||||
* CM provides you _tools_, different ways the Bot can detect patterns in your subreddit/users as well as actions it can, and you can decide to use them however you want.
|
||||
* Your bot is **only accessible to moderators of your subreddit.**
|
||||
|
||||
# Getting Started
|
||||
|
||||
The [Getting Started](/docs/subreddit/gettingStarted.md) guide lays out the steps needed to go from nothing to a working Bot. If you are a moderator new to Context Mod this is where you want to begin.
|
||||
|
||||
# Accessing The Bot
|
||||
|
||||
All bot management and editing is done through the [web interface.](/docs/images/subredditStatus.jpg) The URL used for accessing this interface is given to you by the **Operator** once they have agreed to host your bot/subreddit.
|
||||
|
||||
NOTE: This interface is **only access to moderators of your subreddit** and [guests.](#guest-access) You must login to the web interface **with your moderator account** in order to access it.
|
||||
|
||||
A **guided tour** that helps show how to manage the bot at a high-level is available on the web interface by clicking the **Help** button in the top-right of the page.
|
||||
|
||||
## Editing The Bot
|
||||
|
||||
Find the [editor in the web interface](/docs/webInterface.md#editingupdating-your-config) to access the built-in editor for the bot.
|
||||
|
||||
[The editor](/docs/images/editor.jpg) should be your all-in-one location for viewing and editing your bot's behavior. **It is equivalent to Automoderator's editor page.**
|
||||
|
||||
The editor features:
|
||||
|
||||
* syntax validation and highlighting
|
||||
* configuration auto-complete and documentation (hover over properties)
|
||||
* built-in validation using Microsoft Word "squiggly lines" indicators and an error list at the bottom of the window
|
||||
* built-in saving (at the top of the window)
|
||||
|
||||
# Configuration
|
||||
|
||||
Use the [Configuration Reference](/docs/subreddit/components/README.md) to learn about all the different components available for building a CM configuration.
|
||||
|
||||
Additionally, refer to [How It Works](/docs/README.md#how-it-works) and [Core Concepts](/docs/README.md#concepts) to learn the basic of CM configuration.
|
||||
|
||||
After you have the basics under your belt you could use the [subreddit configurations cookbook](/docs/subreddit/components/cookbook) to familiarize yourself with a complete configuration and ways to use CM.
|
||||
|
||||
# Guest Access
|
||||
|
||||
CM supports **Guest Access**. Reddit users who are given Guest Access to your bot are allowed to access the web interface even though they are not moderators.
|
||||
|
||||
Additionally, they can edit the subreddit's config using the bot. If a Guest edits your config their username will be mentioned in the wiki page edit reason.
|
||||
|
||||
Guests can do everything a regular mod can except view/add/remove Guest. They can be removed at any time or set with an expiration date that their access is removed on.
|
||||
|
||||
**Guests are helpful if you are new to CM and know reddit users that can help you get started.**
|
||||
|
||||
[Add guests from the Subreddit tab in the main interface.](/docs/images/guests.jpg)
|
||||
@@ -22,6 +22,7 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
|
||||
* [Regex](#regex)
|
||||
* [Repost](#repost)
|
||||
* [Sentiment Analysis](#sentiment-analysis)
|
||||
* [Toxic Content Prediction](#moderatehatespeechcom-predictions)
|
||||
* [Rule Sets](#rule-sets)
|
||||
* [Actions](#actions)
|
||||
* [Named Actions](#named-actions)
|
||||
@@ -69,6 +70,7 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
|
||||
* [Rule Order](#rule-order)
|
||||
* [Configuration Re-use and Caching](#configuration-re-use-and-caching)
|
||||
* [Partial Configurations](#partial-configurations)
|
||||
* [Sharing Configs Between Subreddits](#sharing-full-configs-as-runs)
|
||||
* [Subreddit-ready examples](#subreddit-ready-examples)
|
||||
|
||||
# Runs
|
||||
@@ -377,6 +379,12 @@ This rule is for searching **all of Reddit** for reposts, as opposed to just the
|
||||
|
||||
The **Sentiment Rule** is used to determine the overall emotional intent (negative, neutral, positive) of a Submission or Comment by analyzing the actual text content of the Activity.
|
||||
|
||||
### ModerateHateSpeech.com Predictions
|
||||
|
||||
[**Full Documentation**](/docs/subreddit/components/mhs)
|
||||
|
||||
ContextMod integrates with [moderatehatespeech.com](https://moderatehatespeech.com/) (MHS) [toxic content machine learning model](https://moderatehatespeech.com/framework/) through their API. This rule sends an Activity's content (title or body) to MHS which returns a prediction on whether the content is toxic and actionable by a moderator. Their model is [specifically trained for reddit content.](https://www.reddit.com/r/redditdev/comments/xdscbo/updated_bot_backed_by_moderationoriented_ml_for/)
|
||||
|
||||
# Rule Sets
|
||||
|
||||
The `rules` list on a `Check` can contain both `Rule` objects and `RuleSet` objects.
|
||||
@@ -529,6 +537,25 @@ actions:
|
||||
targets: string # 'self' or 'parent' or 'https://reddit.com/r/someSubreddit/21nfdi....'
|
||||
```
|
||||
|
||||
### Comment As Subreddit
|
||||
|
||||
ContextMod can comment [as the subreddit](https://www.reddit.com/r/modnews/comments/wpy5c8/announcing_remove_as_a_subreddit/) using the `/u/subreddit-ModTeam` account with some restrictions:
|
||||
|
||||
* The activity being replied to must ALREADY BE REMOVED.
|
||||
* You can use the [Remove Action](#remove) beforehand to ensure this is the case.
|
||||
* The created comment will always be stickied and distinguished
|
||||
|
||||
Usage:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: comment
|
||||
asModTeam: true
|
||||
content: string # required, the content of the comment
|
||||
lock: boolean # lock the comment after creation
|
||||
targets: string # 'self' or 'parent' or 'https://reddit.com/r/someSubreddit/21nfdi....'
|
||||
```
|
||||
|
||||
### Submission
|
||||
|
||||
Create a Submission [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FSubmissionActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
|
||||
@@ -747,7 +774,7 @@ actions:
|
||||
- kind: usernote
|
||||
type: spamwarn
|
||||
content: 'Usernote message'
|
||||
allowDuplicate: boolean # if false then the usernote will not be added if the same note appears for this activity
|
||||
existingNoteCheck: boolean # if true (default) then the usernote will not be added if the same note appears for this activity
|
||||
```
|
||||
|
||||
### Mod Note
|
||||
@@ -772,6 +799,7 @@ actions:
|
||||
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
|
||||
existingNoteCheck: boolean # if true (default) then the note will not be added if the same note appears for this activity
|
||||
```
|
||||
|
||||
# Filters
|
||||
@@ -1239,6 +1267,49 @@ The object contains:
|
||||
* `path` -- REQUIRED string following rules above
|
||||
* `ttl` -- OPTIONAL, number of seconds to cache the URL result. Defaults to `WikiTTL`
|
||||
|
||||
### Sharing Full Configs as Runs
|
||||
|
||||
If the Fragment fetched by CM is a "full config" (including `runs`, `polling`, etc...) that could be used as a valid config for another subreddit then CM will extract and use the **Runs** from that config.
|
||||
|
||||
**However, the config must also explicitly allow access for use as a Fragment.** This is to prevent subreddits that share a Bot account from accidentally (or intentionally) gaining access to another subreddit's config with permissions.
|
||||
|
||||
#### Sharing
|
||||
|
||||
The config that will be shared (accessed at `wiki:botconfig/contextbot|SharingSubreddit`) must have the `sharing` property defined at its top-level. If `sharing` is not defined access will be denied for all subreddits.
|
||||
|
||||
```yaml
|
||||
sharing: false # deny access to all subreddits (default when sharing is not defined)
|
||||
|
||||
polling:
|
||||
- newComm
|
||||
|
||||
runs:
|
||||
# ...
|
||||
```
|
||||
|
||||
```yaml
|
||||
sharing: true # any subreddit can use this config (reddit account must also be able to access wiki page)
|
||||
```
|
||||
|
||||
```yaml
|
||||
# when a list is given all subreddit names that match any from the list are ALLOWED to access the config
|
||||
# list can be regular expressions or case-insensitive strings
|
||||
sharing:
|
||||
- mealtimevideos
|
||||
- videos
|
||||
- '/Ask.*/i'
|
||||
```
|
||||
|
||||
```yaml
|
||||
# if `exclude` is used then any subreddit name that is NOT on this list can access the config
|
||||
# list can be regular expressions or case-insensitive strings
|
||||
sharing:
|
||||
exclude:
|
||||
- mealtimevideos
|
||||
- videos
|
||||
- '/Ask.*/i'
|
||||
```
|
||||
|
||||
#### Examples
|
||||
|
||||
**Replacing A Rule with a URL Fragment**
|
||||
@@ -1261,7 +1332,7 @@ runs:
|
||||
subreddits:
|
||||
- MyBadSubreddit
|
||||
window: 7 days
|
||||
actions:
|
||||
actions:
|
||||
- kind: report
|
||||
content: 'uses freekarma subreddits and bad subreddits'
|
||||
```
|
||||
@@ -1296,6 +1367,33 @@ runs:
|
||||
content: 'uses freekarma subreddits'
|
||||
```
|
||||
|
||||
**Using Another Subreddit's Config**
|
||||
|
||||
```yaml
|
||||
runs:
|
||||
- `wiki:botconfig/contextbot|SharingSubreddit`
|
||||
- name: MySubredditSpecificRun
|
||||
checks:
|
||||
- name: Free Karma Alert
|
||||
description: Check if author has posted in 'freekarma' subreddits
|
||||
kind: submission
|
||||
rules:
|
||||
- 'wiki:freeKarmaFrag'
|
||||
actions:
|
||||
- kind: report
|
||||
content: 'uses freekarma subreddits'
|
||||
```
|
||||
|
||||
In `r/SharingSubreddit`:
|
||||
|
||||
```yaml
|
||||
sharing: true
|
||||
|
||||
runs:
|
||||
- name: ARun
|
||||
# ...
|
||||
```
|
||||
|
||||
# Subreddit-Ready Examples
|
||||
|
||||
Refer to the [Subreddit-Ready Examples](/docs/subreddit/components/subredditReady) section to find ready-to-use configurations for common scenarios (spam, freekarma blocking, etc...). This is also a good place to familiarize yourself with what complete configurations look like.
|
||||
Refer to the [Subreddit Cookbook Examples](/docs/subreddit/components/cookbook) section to find ready-to-use configurations for common scenarios (spam, freekarma blocking, etc...). This is also a good place to familiarize yourself with what complete configurations look like.
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
// if the nested rules pass the condition then the Rule Set triggers the Check
|
||||
//
|
||||
// AND = all nested rules must be triggered to make the Rule Set trigger
|
||||
// AND = any of the nested Rules will be the Rule Set trigger
|
||||
// OR = any of the nested Rules will be the Rule Set trigger
|
||||
"condition": "AND",
|
||||
// in this check we use an Attribution >10% on ONLY submissions, which is a lower requirement then the above attribution rule
|
||||
// and combine it with a History rule looking for low comment engagement
|
||||
|
||||
@@ -22,7 +22,7 @@ runs:
|
||||
# if the nested rules pass the condition then the Rule Set triggers the Check
|
||||
#
|
||||
# AND = all nested rules must be triggered to make the Rule Set trigger
|
||||
# AND = any of the nested Rules will be the Rule Set trigger
|
||||
# OR = any of the nested Rules will be the Rule Set trigger
|
||||
- condition: AND
|
||||
# in this check we use an Attribution >10% on ONLY submissions, which is a lower requirement then the above attribution rule
|
||||
# and combine it with a History rule looking for low comment engagement
|
||||
|
||||
@@ -8,7 +8,24 @@ The **Attribution** rule will aggregate an Author's content Attribution (youtube
|
||||
|
||||
Consult the [schema](https://json-schema.app/view/%23/%23%2Fdefinitions%2FCheckJson/%23%2Fdefinitions%2FAttributionJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
|
||||
|
||||
### Examples
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
* Self Promotion as percentage of all Activities [YAML](/docs/subreddit/componentscomponents/attribution/redditSelfPromoAll.yaml) | [JSON](/docs/subreddit/componentscomponents/attribution/redditSelfPromoAll.json5) - Check if Author is submitting much more than they comment.
|
||||
| Name | Description | Example |
|
||||
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | 1 Attribution(s) met the threshold of >= 20%, with 6 (40%) of 15 Total -- window: 3 years |
|
||||
| `triggeredDomainCount` | Number of domains that met the threshold | 1 |
|
||||
| `window` | Number or duration of Activities considered from window | 3 years |
|
||||
| `largestCount` | The count from the largest aggregated domain | 6 |
|
||||
| `largestPercentage` | The percentage of Activities the largest aggregated domain comprises | 40% |
|
||||
| `smallestCount` | The count from the smallest aggregated domain | 1 |
|
||||
| `smallestPercentage` | The percentage of Activities the smallest aggregated domain comprises | 6% |
|
||||
| `countRange` | A convenience string displaying "smallestCount - largestCount" or just one number if both are the same | 5 |
|
||||
| `percentRange` | A convenience string displaying "smallestPercentage - largestPercentage" or just one percentage if both are the same | 34% |
|
||||
| `domainsDelim` | A comma-delimited list of all the domain URLs that met the threshold | youtube.com/example1, youtube.com/example2, rueters.com |
|
||||
| `titlesDelim` | A comma-delimited list of friendly-names of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => "My Youtube Channel Title") | My Channel A, My Channel B, reuters.com |
|
||||
| `threshold` | The threshold you configured for this Rule to trigger | `>= 20%` |
|
||||
|
||||
# Examples
|
||||
|
||||
* Self Promotion as percentage of all Activities [YAML](/docs/subreddit/components/attribution/redditSelfPromoAll.yaml) | [JSON](/docs/subreddit/components/attribution/redditSelfPromoAll.json5) - Check if Author is submitting much more than they comment.
|
||||
* Self Promotion as percentage of Submissions [YAML](/docs/subreddit/components/attribution/redditSelfPromoSubmissionsOnly.yaml) | [JSON](/docs/examplesm/attribution/redditSelfPromoSubmissionsOnly.json5) - Check if any of Author's aggregated submission origins are >10% of their submissions
|
||||
|
||||
@@ -9,7 +9,7 @@ The **Author** rule triggers if any [AuthorCriteria](https://json-schema.app/vie
|
||||
* author's subreddit flair text
|
||||
* author's subreddit flair css
|
||||
* author's subreddit mod status
|
||||
* [Toolbox User Notes](/docs/subreddit/componentscomponents/userNotes)
|
||||
* [Toolbox User Notes](/docs/subreddit/components/userNotes)
|
||||
|
||||
The Author **Rule** is best used in conjunction with other Rules to short-circuit a Check based on who the Author is. It is easier to use a Rule to do this then to write **author filters** for every Rule (and makes Rules more re-useable).
|
||||
|
||||
@@ -18,10 +18,10 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorRule
|
||||
### Examples
|
||||
|
||||
* Basic examples
|
||||
* Flair new user Submission [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - If the Author does not have the `vet` flair then flair the Submission with `New User`
|
||||
* Flair vetted user Submission [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - If the Author does have the `vet` flair then flair the Submission with `Vetted`
|
||||
* Flair new user Submission [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - If the Author does not have the `vet` flair then flair the Submission with `New User`
|
||||
* Flair vetted user Submission [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - If the Author does have the `vet` flair then flair the Submission with `Vetted`
|
||||
* Used with other Rules
|
||||
* Ignore vetted user [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - Short-circuit the Check if the Author has the `vet` flair
|
||||
* Ignore vetted user [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - Short-circuit the Check if the Author has the `vet` flair
|
||||
|
||||
## Filter
|
||||
|
||||
@@ -35,7 +35,7 @@ All **Rules** and **Checks** have an optional `authorIs` property that takes an
|
||||
|
||||
### Examples
|
||||
|
||||
* Skip recent activity check based on author [YAML](/docs/subreddit/componentscomponents/author/authorFilter.yaml) | [JSON](/docs/subreddit/componentscomponents/author/authorFilter.json5) - Skip a Recent Activity check for a set of subreddits if the Author of the Submission has any set of flairs.
|
||||
* Skip recent activity check based on author [YAML](/docs/subreddit/components/author/authorFilter.yaml) | [JSON](/docs/subreddit/components/author/authorFilter.json5) - Skip a Recent Activity check for a set of subreddits if the Author of the Submission has any set of flairs.
|
||||
|
||||
## Flair users and submissions
|
||||
|
||||
@@ -45,4 +45,4 @@ Consult [User Flair schema](https://json-schema.app/view/%23%2Fdefinitions%2FUse
|
||||
|
||||
### Examples
|
||||
|
||||
* OnlyFans submissions [YAML](/docs/subreddit/componentscomponents/author/onlyfansFlair.yaml) | [JSON](/docs/subreddit/componentscomponents/author/onlyfansFlair.json5) - Check whether submitter has typical OF keywords in their profile and flair both author + submission accordingly.
|
||||
* OnlyFans submissions [YAML](/docs/subreddit/components/author/onlyfansFlair.yaml) | [JSON](/docs/subreddit/components/author/onlyfansFlair.json5) - Check whether submitter has typical OF keywords in their profile and flair both author + submission accordingly.
|
||||
|
||||
201
docs/subreddit/components/cookbook/README.md
Normal file
@@ -0,0 +1,201 @@
|
||||
# ContextMod Cookbook
|
||||
|
||||
Here you will find useful configs for CM that provide real-world functionality. This is where you should look first for **"how do i..."** questions.
|
||||
|
||||
## How To Use
|
||||
|
||||
Each recipe includes what type of config piece it is (Rule, Check, Action, Run, etc...). Keep this in mind before copy-pasting to make sure it goes in the right place in your config.
|
||||
|
||||
### Copy-Pasting
|
||||
|
||||
If the type is **Check** or **Run** the recipe contents will have instructions in the comments on how to use it as a **full subreddit config** OR **by itself (default).** If not Check/Run then when copy-pasting you will need to ensure it is placed in the correct spot in your config.
|
||||
|
||||
|
||||
### As Config Fragment
|
||||
|
||||
**Checks, Runs, Actions, and Rule** recipes can be referenced in your config without copy-pasting by using them as [Config Fragments.](/docs/subreddit/components/README.md#partial-configurations) These need to be placed in the correct spot in your config, just like copy-pasting, but only require the URL of the recipe instead of all the code.
|
||||
|
||||
To use a recipe as a fragment **copy** the URL of the config and insert into your config like this:
|
||||
|
||||
```yaml
|
||||
- 'url:https://URL_TO_CONFIG'
|
||||
```
|
||||
|
||||
EXAMPLE: Using the **Config** link from the [Free Karma](#remove-submissions-from-users-who-have-used-freekarma-subs-to-bypass-karma-checks) check below -- copy the **Config** link and insert it into a full subreddit config like this:
|
||||
|
||||
<details>
|
||||
<summary>Config</summary>
|
||||
|
||||
```yaml
|
||||
polling:
|
||||
- newSub
|
||||
runs:
|
||||
- name: MyFirstRun
|
||||
checks:
|
||||
# freekarma check
|
||||
- 'url:https://github.com/FoxxMD/context-mod/blob/master/docs/subreddit/components/cookbook/freekarma.yaml'
|
||||
- name: MyRegularCheck
|
||||
kind: submission
|
||||
# ...
|
||||
```
|
||||
</details>
|
||||
|
||||
# Recipes
|
||||
|
||||
## Spam Prevention
|
||||
|
||||
### Remove submissions from users who have used 'freekarma' subs to bypass karma checks
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/freekarma.yaml)
|
||||
|
||||
If the user has any activity (comment/submission) in known freekarma subreddits in the past (100 activities) then remove the submission.
|
||||
|
||||
### Remove submissions that are consecutively spammed by the author
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/crosspostSpam.yaml)
|
||||
|
||||
If the user has crossposted the same submission in the past (100 activities) 4 or more times in a row then remove the submission.
|
||||
|
||||
### Remove submissions if users is flooding new
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/floodingNewSubmissions.yaml)
|
||||
|
||||
If the user has made more than 4 submissions in your subreddit in the last 24 hours than new submissions are removed and user is tagged with a modnote.
|
||||
|
||||
### Remove submissions posted in diametrically-opposed subreddit
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/diametricSpam.yaml)
|
||||
|
||||
If the user makes the same submission to another subreddit(s) that are "thematically" opposed to your subreddit it is probably spam. This check removes it. Detects all types of submissions (including images).
|
||||
|
||||
### Remove comments that are consecutively spammed by the author
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/commentSpam.yaml)
|
||||
|
||||
If the user made the same comment (with some fuzzy matching) 4 or more times in a row in the past (100 activities or 6 months) then remove the comment.
|
||||
|
||||
### Remove comment if it is a chat invite link spam
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/chatSpam.yaml)
|
||||
|
||||
This rule goes a step further than automod can by being more discretionary about how it handles this type of spam.
|
||||
|
||||
* Remove the comment if:
|
||||
* Comment being checked contains **only** a chat link (no other text) OR
|
||||
* Chat links appear **anywhere** in three or more of the last 100 comments the Author has made
|
||||
|
||||
This way ContextMod can more easily distinguish between these use cases for a user commenting with a chat link:
|
||||
|
||||
* actual spammers who only spam a chat link
|
||||
* users who may comment with a link but have context for it either in the current comment or in their history
|
||||
* users who many comment with a link but it's a one-off event (no other links historically)
|
||||
|
||||
## Repost Detection
|
||||
|
||||
### Remove comments reposted from youtube video submissions
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/youtubeCommentRepost.yaml)
|
||||
|
||||
**Requires bot has an API Key for Youtube.**
|
||||
|
||||
Removes comment on reddit if the same comment is found on the youtube video the submission is for.
|
||||
|
||||
### Remove comments reposted from reddit submissions
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/commentRepost.yaml)
|
||||
|
||||
Checks top-level comments on submissions younger than 30 minutes:
|
||||
* Finds other reddit submissions based on crosspost/duplicates/title/URL, takes top 10 submissions based # of upvotes
|
||||
* If this comment matches any top comments from those other submissions with at least 85% sameness then it is considered a repost and removed
|
||||
|
||||
### Remove reposted reddit submission
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/submissionRepost.yaml)
|
||||
|
||||
Checks reddit for top posts with a **Title** that is 90% or more similar to the submission being checked and removes it, if found.
|
||||
|
||||
## Self Promotion
|
||||
|
||||
### Remove link submissions where the user's history is comprised of 10% or more of the same link
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/selfPromo.yaml)
|
||||
|
||||
If the link origin (youtube author, twitter author, etc. or regular domain for non-media links)
|
||||
|
||||
* comprises 10% or more of the users **entire** history in the past (100 activities or 6 months)
|
||||
* or comprises 10% or more of the users **submission** history in the past (100 activities or 6 months) and the user has low engagement (<50% of history is comments or 40%> of comment are as OP)
|
||||
|
||||
then remove the submission
|
||||
|
||||
### Remove submissions posted in 'newtube' subreddits
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/newtube.yaml)
|
||||
|
||||
If the user makes the same submission to a 'newtube' or self-promotional subreddit it is removed and a modnote is added.
|
||||
|
||||
## Safety
|
||||
|
||||
### Remove comments on brigaded submissions when user has no history
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/brigadingNoHistory.yaml)
|
||||
|
||||
The users of comments on a brigaded submission (based on a special submission flair) have their comment history checked -- if they have no participation in your subreddit then the comment is removed.
|
||||
|
||||
### Remove submissions from users with a history of sex solicitation
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/sexSolicitationHistory.yaml)
|
||||
|
||||
If the author of a submission has submissions in their history that match common reddit "sex solicitation" tags (MFA, R4F, M4F, etc...) the submission is removed and a modnote added.
|
||||
|
||||
This is particularly useful for subreddits with underage audiences or mentally/emotionally vulnerable groups.
|
||||
|
||||
The check can be modified to removed comments by changing `kind: submission` to `kind: comment`
|
||||
|
||||
## Verification
|
||||
|
||||
### Verify users from r/TranscribersOfReddit
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/transcribersOfReddit.yaml)
|
||||
|
||||
[r/TranscribersOfReddit](https://www.reddit.com/r/transcribersofreddit) is a community of volunteers transcribing images and videos, across reddit, into plain text.
|
||||
|
||||
This Check detects their standard transcription template and also checks they have a history in r/transcribersofreddit -- then approves the comment and flairs the user with **Transcriber ✍️**
|
||||
|
||||
### Require submission authors have prior subreddit participation
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/requireNonOPParticipation.yaml)
|
||||
|
||||
Submission is removed if the author has **less than 5 non-OP comments** in your subreddit prior to making the submission.
|
||||
|
||||
### Require submission authors make a top-level comment with 15 minutes of posting
|
||||
|
||||
* Type: **Check**
|
||||
* [Config](/docs/subreddit/components/cookbook/requireNonOPParticipation.yaml)
|
||||
|
||||
After making a submission the author must make a top-level comment with a regex-checkable pattern within X minutes. If the comment is not made the submission is removed.
|
||||
|
||||
# Monitoring
|
||||
|
||||
### Sticky a comment on popular submissions
|
||||
|
||||
* Type: **Run**
|
||||
* [Config](/docs/subreddit/components/cookbook/popularSubmissionMonitoring.yaml)
|
||||
|
||||
This **Run** should come after any other Runs you have that may remove a Submission.
|
||||
|
||||
The Run will cause CM to check new submissions for 3 hours at a 10 minute interval. The bot will then make a comment and sticky it WHEN it detects the number of upvotes is abnormal for how long the Submission has been "alive".
|
||||
44
docs/subreddit/components/cookbook/brigadingNoHistory.yaml
Normal file
@@ -0,0 +1,44 @@
|
||||
#polling:
|
||||
# - newComm
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Report comments from users with no history in the subreddit IF the submission is flaired as being brigaded
|
||||
# optionally, remove comment
|
||||
#
|
||||
- name: Brigading No History
|
||||
kind: comment
|
||||
# only runs on comments in a submission with a link flair css class of 'brigaded'
|
||||
itemIs:
|
||||
- submissionState:
|
||||
# can use any or all of these to detect brigaded submission
|
||||
- link_flair_css: brigaded
|
||||
#flairTemplate: 123-1234
|
||||
#link_flair_text: Restricted
|
||||
rules:
|
||||
- name: noHistory
|
||||
kind: recentActivity
|
||||
# check last 100 activities that have not been removed
|
||||
window:
|
||||
count: 100
|
||||
filterOn:
|
||||
post:
|
||||
commentState:
|
||||
include:
|
||||
- removed: false
|
||||
thresholds:
|
||||
# triggers if user has only one activity (this one) in your subreddit
|
||||
- subreddits:
|
||||
- MYSUBREDDIT
|
||||
threshold: '<= 1'
|
||||
actions:
|
||||
- kind: report
|
||||
enable: true
|
||||
content: User has no history in subreddit
|
||||
|
||||
- kind: remove
|
||||
enable: false
|
||||
note: User has no history in subreddit
|
||||
@@ -1,9 +1,18 @@
|
||||
polling:
|
||||
- newComm
|
||||
runs:
|
||||
- checks:
|
||||
- name: ban discord only spammer
|
||||
description: ban a user who spams only a discord link many times historically
|
||||
#polling:
|
||||
# - newComm
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Remove comments from users who spam discord and telegram links
|
||||
# -- differs from just using automod:
|
||||
# 1) removes comment if it is ONLY discord/telegram link
|
||||
# 2) if not *only* link then checks user's history to see if link is spammed many times and only removes if it is
|
||||
#
|
||||
- name: ban chat only spammer
|
||||
description: ban a user who spams only a chat link many times historically
|
||||
kind: comment
|
||||
condition: AND
|
||||
rules:
|
||||
@@ -13,9 +22,9 @@ runs:
|
||||
- kind: remove
|
||||
- kind: ban
|
||||
content: spamming discord links
|
||||
- name: remove discord spam
|
||||
- name: remove chat spam
|
||||
description: >-
|
||||
remove comments from users who only link to discord or mention discord
|
||||
remove comments from users who only link to chat or mention chat
|
||||
link many times historically
|
||||
kind: comment
|
||||
condition: OR
|
||||
@@ -24,8 +33,9 @@ runs:
|
||||
kind: regex
|
||||
criteria:
|
||||
- name: only link
|
||||
# https://regexr.com/70j9m
|
||||
# single quotes are required to escape special characters
|
||||
regex: '/^.*(discord\.gg\/[\w\d]+)$/i'
|
||||
regex: '/^\s*((?:discord\.gg|t\.me|telegram\.me|telegr\.im)\/[\w\d]+)\s*$/i'
|
||||
- condition: AND
|
||||
rules:
|
||||
- name: linkAnywhereSpam
|
||||
@@ -33,15 +43,16 @@ runs:
|
||||
criteria:
|
||||
- name: contains link anywhere
|
||||
# single quotes are required to escape special characters
|
||||
regex: '/^.*(discord\.gg\/[\w\d]+).*$/i'
|
||||
regex: '/((?:discord\.gg|t\.me|telegram\.me|telegr\.im)\/[\w\d]+)/i'
|
||||
- name: linkAnywhereHistoricalSpam
|
||||
kind: regex
|
||||
criteria:
|
||||
- name: contains links anywhere historically
|
||||
# single quotes are required to escape special characters
|
||||
regex: '/^.*(discord\.gg\/[\w\d]+).*$/i'
|
||||
regex: '/((?:discord\.gg|t\.me|telegram\.me|telegr\.im)\/[\w\d]+)/i'
|
||||
totalMatchThreshold: '>= 3'
|
||||
lookAt: comments
|
||||
window: 10
|
||||
window: 100
|
||||
actions:
|
||||
- kind: remove
|
||||
note: Chat spam link
|
||||
@@ -0,0 +1,71 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
# - newComm
|
||||
#runs:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a series of RUNS
|
||||
- name: approvals
|
||||
checks:
|
||||
- name: approveSubmissionOnComment
|
||||
description: Approve an unapproved submission when OP comments with the magic words
|
||||
kind: comment
|
||||
itemIs:
|
||||
# only check comment if submission is not approved and this comment is by OP
|
||||
- submissionState:
|
||||
- approved: false
|
||||
op: true
|
||||
rules:
|
||||
- name: OPMagic
|
||||
kind: regex
|
||||
criteria:
|
||||
# YOU NEED TO EDIT THIS REGEX TO MATCH THE PATTERN THE OP'S COMMENT SHOULD HAVE IN ORDER TO VERIFY THE SUBMISSION
|
||||
- regex: '/Say Please/i'
|
||||
actions:
|
||||
- kind: approve
|
||||
targets:
|
||||
- parent
|
||||
- self
|
||||
# cancel any delayed dispatched actions
|
||||
- kind: cancelDispatch
|
||||
# tell action to look for delayed items matched parent (submission)
|
||||
target: parent
|
||||
# submission must have 'subVerification' identifier
|
||||
identifier: subVerification
|
||||
|
||||
- name: verification
|
||||
checks:
|
||||
- name: waitForVerification
|
||||
description: Delay processing this submission for 15 minutes
|
||||
kind: submission
|
||||
itemIs:
|
||||
# only dispatch if this is the first time we are seeing this submission
|
||||
- source:
|
||||
- "poll:newSub"
|
||||
- user
|
||||
actions:
|
||||
- kind: dispatch
|
||||
target: self
|
||||
# unique identifier which is a nice hint in the UI and also allows targeting this item while it is delayed
|
||||
identifier: subVerification
|
||||
delay: "15 minutes"
|
||||
# when it is reprocessed go directly to the 'verification' run, skipping everything else
|
||||
goto: verification
|
||||
|
||||
- name: removeNoVerification
|
||||
description: Remove submission if it is not verified after delay
|
||||
kind: submission
|
||||
itemIs:
|
||||
# only process this submission if it comes dispatch with 'subVerification' identifier and is NOT approved after 15 minutes
|
||||
- source: "dispatch:subVerification"
|
||||
approved: false
|
||||
actions:
|
||||
# if this submission is being processed it has been 5 minutes and was not cancelled by OF comment
|
||||
- kind: remove
|
||||
enable: true
|
||||
|
||||
- kind: comment
|
||||
enable: true
|
||||
lock: true
|
||||
distinguish: true
|
||||
content: 'Your submission has been removed because you did not follow verification instructions within 15 minutes of posting.'
|
||||
54
docs/subreddit/components/cookbook/commentRepost.yaml
Normal file
@@ -0,0 +1,54 @@
|
||||
#polling:
|
||||
# - newComm
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Checks top-level comments on submissions younger than 30 minutes:
|
||||
# * Finds other reddit submissions based on crosspost/duplicates/title/URL, takes top 10 submissions based # of upvotes
|
||||
# * If this comment matches any comments from those other submissions with at least 85% sameness then it is considered repost
|
||||
#
|
||||
# optionally, bans user if they have more than one modnote for comment reposts
|
||||
#
|
||||
- name: commRepost
|
||||
description: Check if comment has been reposted from youtube
|
||||
kind: comment
|
||||
itemIs:
|
||||
- removed: false
|
||||
approved: false
|
||||
op: false
|
||||
# top level comments only
|
||||
depth: '< 1'
|
||||
submissionState:
|
||||
- age: '< 30 minutes'
|
||||
condition: AND
|
||||
rules:
|
||||
- name: commRepost
|
||||
kind: repost
|
||||
criteria:
|
||||
- searchOn:
|
||||
- external
|
||||
actions:
|
||||
- kind: remove
|
||||
spam: true
|
||||
note: 'reposted comment from reddit with {{rules.commrepost.closestSameness}}% sameness'
|
||||
|
||||
- kind: ban
|
||||
authorIs:
|
||||
# if the author has more than one spamwatch usernote then just ban em
|
||||
include:
|
||||
- modActions:
|
||||
- noteType: SPAM_WATCH
|
||||
note: "/comment repost.*/i"
|
||||
search: total
|
||||
count: "> 1"
|
||||
message: You have been banned for repeated spammy behavior including reposting reddit comments
|
||||
note: reddit comment repost + spammy behavior
|
||||
reason: reddit comment repost + spammy behavior
|
||||
|
||||
- name: commRepostModNote
|
||||
kind: modnote
|
||||
content: 'YT comment repost with {{rules.commrepost.closestSameness}}% sameness'
|
||||
type: SPAM_WATCH
|
||||
@@ -1,14 +1,18 @@
|
||||
polling:
|
||||
- newComm
|
||||
runs:
|
||||
- checks:
|
||||
# Stop users who spam the same comment many times
|
||||
#polling:
|
||||
# - newComm
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Remove comments by users who spam the same comment many times
|
||||
#
|
||||
- name: low xp comment spam
|
||||
description: X-posted comment >=4x
|
||||
kind: comment
|
||||
condition: AND
|
||||
rules:
|
||||
- name: xPostLow
|
||||
- name: xPostLowComm
|
||||
kind: repeatActivity
|
||||
# number of "non-repeat" comments allowed between "repeat comments"
|
||||
gapAllowance: 2
|
||||
@@ -16,11 +20,13 @@ runs:
|
||||
threshold: '>= 4'
|
||||
# retrieve either last 50 comments or 6 months' of history, whichever is less
|
||||
window:
|
||||
count: 50
|
||||
count: 100
|
||||
duration: 6 months
|
||||
actions:
|
||||
- kind: report
|
||||
enable: true
|
||||
content: 'Remove => Posted same comment {{rules.xpostlow.largestRepeat}}x times'
|
||||
enable: false
|
||||
content: 'Remove => Posted same comment {{rules.xpostlowcomm.largestRepeat}}x times'
|
||||
|
||||
- kind: remove
|
||||
enable: true
|
||||
note: 'Posted same comment {{rules.xpostlowcomm.largestRepeat}}x times'
|
||||
@@ -1,7 +1,11 @@
|
||||
polling:
|
||||
- unmoderated
|
||||
runs:
|
||||
- checks:
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# stop users who post low-effort, crossposted spam submissions
|
||||
#
|
||||
# Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
|
||||
@@ -18,7 +22,7 @@ runs:
|
||||
gapAllowance: 2
|
||||
threshold: '>= 4'
|
||||
window:
|
||||
count: 50
|
||||
count: 100
|
||||
duration: 6 months
|
||||
- name: lowOrOpComm
|
||||
kind: history
|
||||
@@ -34,12 +38,15 @@ runs:
|
||||
comment: '> 40% OP'
|
||||
actions:
|
||||
- kind: report
|
||||
enable: true
|
||||
enable: false
|
||||
content: >-
|
||||
Remove=>{{rules.xpostlow.largestRepeat}} X-P =>
|
||||
{{rules.loworopcomm.thresholdSummary}}
|
||||
|
||||
- kind: remove
|
||||
enable: true
|
||||
note: 'Repeated submission {{rules.xpostlow.largestRepeat}}x and low comment engagement'
|
||||
|
||||
- kind: comment
|
||||
enable: true
|
||||
content: >-
|
||||
34
docs/subreddit/components/cookbook/diametricSpam.yaml
Normal file
@@ -0,0 +1,34 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
- name: diametricSpam
|
||||
description: Check if author has posted the same image in opposite subs
|
||||
kind: submission
|
||||
rules:
|
||||
- name: recent
|
||||
kind: recentActivity
|
||||
useSubmissionAsReference: true
|
||||
# requires your subreddit to be running on a CM instance that supports image processing
|
||||
imageDetection:
|
||||
enable: true
|
||||
threshold: 5
|
||||
lookAt: submissions
|
||||
window: 30
|
||||
thresholds:
|
||||
- threshold: ">= 1"
|
||||
subreddits:
|
||||
- AnotherSubreddit
|
||||
actions:
|
||||
- kind: remove
|
||||
enable: true
|
||||
content: "Posted same image in {{rules.recent.subSummary}}"
|
||||
|
||||
- kind: comment
|
||||
distinguish: true
|
||||
sticky: true
|
||||
lock: true
|
||||
content: 'You have posted the same image in another subreddit ({{rules.recent.subSummary}}) that does not make sense given the theme of this subreddit. We consider this spam and it has been removed.'
|
||||
@@ -0,0 +1,34 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Add a mote note to users who are making more than 4 submissions a day
|
||||
# and optionally remove new submissions by them
|
||||
#
|
||||
- name: Flooding New
|
||||
description: Detect users make more than 4 submission in 24 hours
|
||||
kind: submission
|
||||
rules:
|
||||
- name: Recent In Sub
|
||||
kind: recentActivity
|
||||
useSubmissionAsReference: false
|
||||
window:
|
||||
duration: 24 hours
|
||||
fetch: submissions
|
||||
thresholds:
|
||||
- subreddits:
|
||||
# change this to your subreddit
|
||||
- MYSUBREDDIT
|
||||
threshold: "> 4"
|
||||
actions:
|
||||
- kind: modnote
|
||||
type: SPAM_WATCH
|
||||
content: '{{rules.recentinsub.totalCount}} submissions in the last 24 hours'
|
||||
|
||||
- kind: remove
|
||||
enable: false
|
||||
note: '{{rules.recentinsub.totalCount}} submissions in the last 24 hours'
|
||||
45
docs/subreddit/components/cookbook/freekarma.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Remove submissions from users who have recent activity in freekarma subs in the last 100 activities
|
||||
#
|
||||
- name: freekarma removal
|
||||
description: Remove submission if user has used freekarma sub recently
|
||||
kind: submission
|
||||
rules:
|
||||
- name: freekarma
|
||||
kind: recentActivity
|
||||
window: 100
|
||||
useSubmissionAsReference: false
|
||||
thresholds:
|
||||
- subreddits:
|
||||
- FreeKarma4U
|
||||
- FreeKarma4You
|
||||
- freekarmaforyou
|
||||
- KarmaFarming4Pros
|
||||
- KarmaStore
|
||||
- upvote
|
||||
- promote
|
||||
- shamelessplug
|
||||
- upvote
|
||||
- FreeUpVotes
|
||||
- GiveMeKarma
|
||||
- nsfwkarma
|
||||
- GetFreeKarmaAnyTime
|
||||
- freekarma2021
|
||||
- FreeKarma2022
|
||||
- KarmaRocket
|
||||
- FREEKARMA4PORN
|
||||
actions:
|
||||
- kind: report
|
||||
enable: false
|
||||
content: 'Remove => {{rules.freekarma.totalCount}} activities in freekarma subs'
|
||||
|
||||
- kind: remove
|
||||
enable: true
|
||||
note: '{{rules.freekarma.totalCount}} activities in freekarma subs'
|
||||
55
docs/subreddit/components/cookbook/newtube.yaml
Normal file
@@ -0,0 +1,55 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Add a mote note to users who make a submission that is also posted to a 'newtube' subreddit
|
||||
# and optionally remove new submission
|
||||
#
|
||||
- name: Newtube Submission
|
||||
description: Tag user if submission was posted in 'newtube' subreddit
|
||||
kind: submission
|
||||
rules:
|
||||
- name: newTube
|
||||
kind: recentActivity
|
||||
window:
|
||||
count: 100
|
||||
fetch: submissions
|
||||
thresholds:
|
||||
- subreddits:
|
||||
- AdvertiseYourVideos
|
||||
- BrandNewTube
|
||||
- FreeKarma4U
|
||||
- FreeKarma4You
|
||||
- KarmaStore
|
||||
- GetMoreSubsYT
|
||||
- GetMoreViewsYT
|
||||
- NewTubers
|
||||
- promote
|
||||
- PromoteGamingVideos
|
||||
- shamelessplug
|
||||
- SelfPromotionYouTube
|
||||
- SmallYTChannel
|
||||
- SmallYoutubers
|
||||
- upvote
|
||||
- youtubestartups
|
||||
- YouTube_startups
|
||||
- YoutubeSelfPromotions
|
||||
- YoutubeSelfPromotion
|
||||
- YouTubeSubscribeBoost
|
||||
- youtubepromotion
|
||||
- YTPromo
|
||||
- Youtubeviews
|
||||
- YouTube_startups
|
||||
actions:
|
||||
- name: newtubeModTag
|
||||
kind: modnote
|
||||
type: SPAM_WATCH
|
||||
content: 'New Tube => {{rules.newtube.subSummary}}{{rules.newtubeall.subSummary}}'
|
||||
|
||||
- kind: remove
|
||||
enable: false
|
||||
note: 'New Tube => {{rules.newtube.subSummary}}{{rules.newtubeall.subSummary}}'
|
||||
@@ -0,0 +1,89 @@
|
||||
polling:
|
||||
- newSub
|
||||
|
||||
runs:
|
||||
- name: MyRegularRun
|
||||
itemIs:
|
||||
# regular run/checks should only run on new activities or if from dashboard
|
||||
- source:
|
||||
- 'poll:newSub'
|
||||
- 'poll:newComm'
|
||||
- 'user'
|
||||
checks:
|
||||
- name: RuleBreakingCheck1
|
||||
kind: submission
|
||||
# ...
|
||||
#
|
||||
# your regular checks go here
|
||||
#
|
||||
# assuming if a Submission makes it through all of your Checks then it is "OK"
|
||||
# to be Approved or generally will be visible in the subreddit (valid for monitoring for r/All)
|
||||
# -- at the end of the Run add a Dispath action
|
||||
- name: Dispatch For Popular Monitoring
|
||||
kind: submission
|
||||
actions:
|
||||
- kind: dispatch
|
||||
identifier: 'popular'
|
||||
# CM will wait 5 minutes before processing this submission again
|
||||
delay: '5 minutes'
|
||||
target: 'self'
|
||||
|
||||
# a separate run that only processes Submissions from dispatch:popular
|
||||
- name: PopularWatch
|
||||
itemIs:
|
||||
- source: 'dispatch:popular'
|
||||
checks:
|
||||
# each check here looks at submission age and tests upvotes against what you think is probably r/All number of votes
|
||||
# in descending age (oldest first)
|
||||
# NOTE: You should change the 'age' and 'score' tests to fit the traffic volume for your subreddit!
|
||||
- name: Two Hour Check
|
||||
kind: submission
|
||||
itemIs:
|
||||
- age: '>= 2 hours'
|
||||
score: '> 100'
|
||||
actions:
|
||||
- kind: comment
|
||||
name: popularComment
|
||||
content: 'Looks like this thread is getting a lot of attention. Greetings r/All! Please keep it civil.'
|
||||
sticky: true
|
||||
distinguish: true
|
||||
lock: true
|
||||
|
||||
- name: One Hour Check
|
||||
kind: submission
|
||||
itemIs:
|
||||
- age: '>= 1 hours'
|
||||
score: '> 50'
|
||||
actions:
|
||||
- popularComment
|
||||
|
||||
- name: Thirty Minute Check
|
||||
kind: submission
|
||||
itemIs:
|
||||
- age: '>= 30 minutes'
|
||||
score: '> 25'
|
||||
actions:
|
||||
- popularComment
|
||||
|
||||
- name: Ten Minute Check
|
||||
kind: submission
|
||||
itemIs:
|
||||
- age: '>= 10 minutes'
|
||||
score: '> 10'
|
||||
actions:
|
||||
- popularComment
|
||||
|
||||
# finally, if none of the popular checks passed re-dispatch submission to be checked in another 10 minutes
|
||||
- name: Delay Popular Check
|
||||
kind: submission
|
||||
postTrigger:
|
||||
# don't need to add this Actioned Events
|
||||
recordTo: false
|
||||
itemIs:
|
||||
# only monitor until submission is 3 hours old
|
||||
- age: '<= 3 hours'
|
||||
actions:
|
||||
- kind: dispatch
|
||||
identifier: 'popular'
|
||||
delay: '10 minutes'
|
||||
target: 'self'
|
||||
@@ -0,0 +1,51 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Report submissions by users with less than 5 non-OP comments in our subreddit
|
||||
# and optionally remove the submission
|
||||
#
|
||||
- name: RequireEngagement
|
||||
description: Remove submission if author has less than X non-op comments in our subreddit
|
||||
kind: submission
|
||||
rules:
|
||||
- name: LittleEngagement
|
||||
kind: recentActivity
|
||||
lookAt: comments
|
||||
useSubmissionAsReference: false
|
||||
# bot will check the last 100 NON-OP comments from user's history
|
||||
window:
|
||||
count: 100
|
||||
fetch: comments
|
||||
filterOn:
|
||||
post:
|
||||
commentState:
|
||||
- op: false
|
||||
thresholds:
|
||||
subreddits:
|
||||
- MYSUBREDDIT
|
||||
# rule is "triggered" if there are LESS THAN 5 comments in our subreddit in the window specified (currently 100 non-op comments)
|
||||
threshold: '< 5'
|
||||
|
||||
actions:
|
||||
|
||||
- kind: report # report the submission
|
||||
enable: true
|
||||
# the text of the report
|
||||
content: 'User has <5 non-OP comments in last 100 comments'
|
||||
|
||||
- kind: remove # remove the submission
|
||||
enable: false
|
||||
note: 'User has <5 non-OP comments in last 100 comments'
|
||||
|
||||
- kind: comment # reply to submission with a comment
|
||||
enable: false
|
||||
# contents of the comment
|
||||
content: We require users to have a minimum level of engagement (>5 comments on other people's posts) in our subreddit before making submissions. Your submission has been automatically removed.
|
||||
sticky: true
|
||||
distinguish: true
|
||||
lock: true
|
||||
@@ -1,7 +1,10 @@
|
||||
polling:
|
||||
- unmoderated
|
||||
runs:
|
||||
- checks:
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Stop users who make link submissions with a self-promotional agenda (with reddit's suggested 10% rule)
|
||||
# https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit
|
||||
@@ -58,8 +61,11 @@ runs:
|
||||
({{rules.attr.window}}{{rules.attrsub.window}}){{#rules.loworopcomm.thresholdSummary}}
|
||||
=>
|
||||
{{rules.loworopcomm.thresholdSummary}}{{/rules.loworopcomm.thresholdSummary}}
|
||||
|
||||
- kind: remove
|
||||
enable: false
|
||||
enable: true
|
||||
note: '>10% of author's history is content from this creator'
|
||||
|
||||
- kind: comment
|
||||
enable: true
|
||||
content: >-
|
||||
@@ -69,4 +75,3 @@ runs:
|
||||
is against [reddit's self promotional
|
||||
guidelines.](https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit)
|
||||
distinguish: true
|
||||
dryRun: true
|
||||
@@ -0,0 +1,33 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Remove submission if user has any "redditor for [sex]..." submissions in their history
|
||||
# and optionally bans user
|
||||
#
|
||||
- name: sexSpamHistory
|
||||
description: Detect sex spam language in recent history and ban if found (most likely a bot)
|
||||
kind: submission
|
||||
rules:
|
||||
- kind: regex
|
||||
name: redditorFor
|
||||
criteria:
|
||||
# matches if text has common "looking for" acronym like F4M R4A etc...
|
||||
- regex: '/[RFM]4[a-zA-Z\s0-9]/i'
|
||||
totalMatchThreshold: "> 1"
|
||||
window: 100
|
||||
testOn:
|
||||
- body
|
||||
- title
|
||||
actions:
|
||||
- kind: remove
|
||||
enable: true
|
||||
note: 'Has sex solicitation submission history: {{rules.redditorfor.matchSample}}'
|
||||
|
||||
- kind: modnote
|
||||
type: ABUSE_WARNING
|
||||
content: 'Has sex solicitation submission history: {{rules.redditorfor.matchSample}}'
|
||||
31
docs/subreddit/components/cookbook/submissionRepost.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
#polling:
|
||||
# - newSub
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
- name: BotRepost
|
||||
description: Remove submission if it is likely a repost
|
||||
kind: submission
|
||||
rules:
|
||||
# search reddit for similar submissions to see if it is a repost
|
||||
- name: subRepost
|
||||
kind: repost
|
||||
criteria:
|
||||
- searchOn:
|
||||
# match found Submissions sameness using title against title of Submission being checked
|
||||
- kind: title
|
||||
# sameness (confidence) % of a title required to consider Submission being checked as a repost
|
||||
matchScore: 90
|
||||
|
||||
actions:
|
||||
# report the submission
|
||||
- kind: report
|
||||
enable: true
|
||||
content: '{{rules.subrepost.closestSameness}} confidence this is a repost.'
|
||||
|
||||
# remove the submission
|
||||
- kind: remove
|
||||
enable: false
|
||||
note: '{{rules.subrepost.closestSameness}} confidence this is a repost.'
|
||||
41
docs/subreddit/components/cookbook/transcribersOfReddit.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
#polling:
|
||||
# - newComm
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# Detect top-level comments by users from r/transcribersofreddit
|
||||
# and approve/flair the user
|
||||
#
|
||||
- name: transcriber comment
|
||||
description: approve/flair transcribed video comment
|
||||
kind: comment
|
||||
itemIs:
|
||||
# top-level comments
|
||||
depth: '< 1'
|
||||
condition: AND
|
||||
rules:
|
||||
- name: transcribedVideoFormat
|
||||
kind: regex
|
||||
criteria:
|
||||
- regex: '/^[\n\r\s]*\*Video Transcription\*[\n\r]+---[\S\s]+---/gim'
|
||||
- name: transcribersActivity
|
||||
kind: recentActivity
|
||||
window:
|
||||
count: 100
|
||||
duration: 1 week
|
||||
useSubmissionAsReference: false
|
||||
thresholds:
|
||||
- subreddits:
|
||||
- transcribersofreddit
|
||||
actions:
|
||||
- kind: approve
|
||||
- name: flairTranscriber
|
||||
kind: flair
|
||||
authorIs:
|
||||
exclude:
|
||||
- flairText:
|
||||
- Transcriber ✍️
|
||||
text: Transcriber ✍️
|
||||
47
docs/subreddit/components/cookbook/youtubeCommentRepost.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
#polling:
|
||||
# - newComm
|
||||
#runs:
|
||||
# - checks:
|
||||
#### Uncomment the code above to use this as a FULL subreddit config
|
||||
####
|
||||
#### Otherwise copy-paste the code below to use as a CHECK
|
||||
#
|
||||
# If submission type is a youtube video CM will check top comments on the video and remove comment if it at least 85% the same
|
||||
# optionally, bans user if they have more than one modnote for comment reposts
|
||||
#
|
||||
- name: commRepostYT
|
||||
description: Check if comment has been reposted from youtube
|
||||
kind: comment
|
||||
itemIs:
|
||||
- removed: false
|
||||
approved: false
|
||||
op: false
|
||||
condition: AND
|
||||
rules:
|
||||
- name: commRepost
|
||||
kind: repost
|
||||
criteria:
|
||||
- searchOn:
|
||||
- external
|
||||
actions:
|
||||
- kind: remove
|
||||
spam: true
|
||||
note: 'reposted comment from youtube with {{rules.commrepostyt.closestSameness}}% sameness'
|
||||
|
||||
- kind: ban
|
||||
authorIs:
|
||||
# if the author has more than one spamwatch usernote then just ban em
|
||||
include:
|
||||
- modActions:
|
||||
- noteType: SPAM_WATCH
|
||||
note: "/comment repost.*/i"
|
||||
search: total
|
||||
count: "> 1"
|
||||
message: You have been banned for repeated spammy behavior including reposting youtube comments
|
||||
note: yt comment repost + spammy behavior
|
||||
reason: yt comment repost + spammy behavior
|
||||
|
||||
- name: commRepostYTModNote
|
||||
kind: modnote
|
||||
content: 'YT comment repost with {{rules.commrepostyt.closestSameness}}% sameness'
|
||||
type: SPAM_WATCH
|
||||
@@ -5,10 +5,64 @@ The **History** rule can check an Author's submission/comment statistics over a
|
||||
* Submission total or percentage of All Activity
|
||||
* Comment total or percentage of all Activity
|
||||
* Comments made as OP (commented in their own Submission) total or percentage of all Comments
|
||||
* Ratio of activities against another window of activities
|
||||
|
||||
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FHistoryJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
|
||||
|
||||
## Ratio
|
||||
|
||||
Use the `ratio` property in Criteria to test the [number of activities](/docs/subreddit/activitiesWindow.md) found in the parent criteria against the number of activities from _another_ [activity window](/docs/subreddit/activitiesWindow.md) defined in the ratio.
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
- kind: history
|
||||
criteria:
|
||||
# "parent" criteria, returns all activities, in the last 100 from user's history, that occurred in r/mealtimevideos
|
||||
- window:
|
||||
count: 100
|
||||
filterOn:
|
||||
post:
|
||||
subreddits:
|
||||
include:
|
||||
- mealtimevideos
|
||||
ratio:
|
||||
# "ratio" criteria, returns all activities, in the last 100 from user's history, that occurred in r/redditdev
|
||||
window:
|
||||
count: 100
|
||||
filterOn:
|
||||
post:
|
||||
subreddits:
|
||||
include:
|
||||
- redditdev
|
||||
# test (number of parent criteria activities) / (number of ratio critieria activities)
|
||||
threshold: '> 1.2'
|
||||
```
|
||||
|
||||
`threshold` may be a number or percentage `(number * 100)`
|
||||
|
||||
* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities
|
||||
* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities
|
||||
|
||||
### Examples
|
||||
|
||||
* Low Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/lowEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/lowEngagement.json5) - Check if Author is submitting much more than they comment.
|
||||
* OP Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content
|
||||
* Low Comment Engagement [YAML](/docs/subreddit/components/history/lowEngagement.yaml) | [JSON](/docs/subreddit/components/history/lowEngagement.json5) - Check if Author is submitting much more than they comment.
|
||||
* OP Comment Engagement [YAML](/docs/subreddit/components/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/components/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content
|
||||
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
| Name | Description | Example |
|
||||
|----------------------|------------------------------------------------------------------------|----------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | Filtered Activities (7) were < 10 Items (2 months) |
|
||||
| `activityTotal` | Total number of activities from window | 50 |
|
||||
| `filteredTotal` | Total number of activities filtered from window | 7 |
|
||||
| `filteredPercent` | Percentage of activities filtered from window | 14% |
|
||||
| `submissionTotal` | Total number of filtered submissions from window | 4 |
|
||||
| `submissionPercent` | Percentage of filtered submissions from window | 8% |
|
||||
| `commentTotal` | Total number of filtered comments from window | 3 |
|
||||
| `commentPercent` | Percentage of filtered comments from window | 6% |
|
||||
| `opTotal` | Total number of comments as OP from filtered comments | 2 |
|
||||
| `opPercent` | Percentage of comments as OP from filtered comments | 66% |
|
||||
| `thresholdSummary` | A text summary of the first Criteria triggered with totals/percentages | Filtered Activities (7) were < 10 Items |
|
||||
| `subredditBreakdown` | A markdown list of filtered activities by subreddit | * SubredditA - 5 (71%) \n * Subreddit B - 2 (28%) |
|
||||
| `window` | Number or duration of Activities considered from window | 2 months |
|
||||
|
||||
177
docs/subreddit/components/mhs/README.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Table of Contents
|
||||
|
||||
* [Overview](#overview)
|
||||
* [MHS Predictions](#mhs-predictions)
|
||||
* [Flagged](#flagged)
|
||||
* [Confidence](#confidence)
|
||||
* [Usage](#usage)
|
||||
* [Minimal/Default Config](#minimaldefault-config)
|
||||
* [Full Config](#full-config)
|
||||
* [Historical Matching](#historical-matching)
|
||||
* [Examples](#examples)
|
||||
|
||||
# Overview
|
||||
|
||||
[moderatehatespeech.com](https://moderatehatespeech.com/) (MHS) is a [non-profit initiative](https://moderatehatespeech.com/about/) to identify and fight toxic and hateful content online using programmatic technology such as machine learning models.
|
||||
|
||||
They offer a [toxic content prediction model](https://moderatehatespeech.com/framework/) specifically trained on and for [reddit content](https://www.reddit.com/r/redditdev/comments/xdscbo/updated_bot_backed_by_moderationoriented_ml_for/) as well as partnering [directly with subreddits.](https://moderatehatespeech.com/research/subreddit-program/).
|
||||
|
||||
Context Mod leverages their [API](https://moderatehatespeech.com/docs/) for toxic content predictions in the **MHS Rule.**
|
||||
|
||||
The **MHS Rule** sends an Activity's content (title or body) to MHS which returns a prediction on whether the content is toxic and actionable by a moderator.
|
||||
|
||||
## MHS Predictions
|
||||
|
||||
MHS's toxic content predictions return two indicators about the content it analyzed. Both are available as test conditions in ContextMod.
|
||||
|
||||
### Flagged
|
||||
|
||||
MHS returns a straight "Toxic or Normal" **flag** based on how it classifies the content.
|
||||
|
||||
Example
|
||||
|
||||
* `Normal` - "I love those pineapples"
|
||||
* `Toxic` - "why are we having all these people from shithole countries coming here"
|
||||
|
||||
### Confidence
|
||||
|
||||
MHS returns how **confident** it is of the flag classification on a scale of 0 to 100.
|
||||
|
||||
Example
|
||||
|
||||
"why are we having all these people from shithole countries coming here"
|
||||
|
||||
* Flag = `Toxic`
|
||||
* Confidence = `97.12` -> The model is 97% confident the content is `Toxic`
|
||||
|
||||
# Usage
|
||||
|
||||
**An MHS Api Key is required to use this Rule**. An API Key can be acquired, for free, by creating an account at [moderatehatespeech.com](https://moderatehatespeech.com).
|
||||
|
||||
The Key can be provided by the bot's Operator in the [bot config credentials](https://json-schema.app/view/%23/%23%2Fdefinitions%2FBotInstanceJsonConfig/%23%2Fdefinitions%2FBotCredentialsJsonConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fedge%2Fsrc%2FSchema%2FOperatorConfig.json) or in the subreddit's config in the top-level `credentials` property like this:
|
||||
|
||||
```yaml
|
||||
credentials:
|
||||
mhs:
|
||||
apiKey: 'myMHSApiKey'
|
||||
|
||||
# the rest of your config below
|
||||
polling:
|
||||
# ...
|
||||
runs:
|
||||
# ...
|
||||
```
|
||||
|
||||
### Minimal/Default Config
|
||||
|
||||
ContextMod provides a reasonable default configuration for the MHS Rule if you do not wish to configure it yourself. The default configuration will trigger the rule if the MHS prediction:
|
||||
|
||||
* flags as `toxic`
|
||||
* with `90% or greater` confidence
|
||||
|
||||
Example
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- kind: mhs
|
||||
|
||||
# rest of your rules here...
|
||||
```
|
||||
|
||||
### Full Config
|
||||
|
||||
|
||||
| Property | Type | Description | Default |
|
||||
|--------------|---------|-------------------------------------------------------------------------------------------|---------|
|
||||
| `flagged` | boolean | Test whether content is flagged as toxic (true) or normal (false) | `true` |
|
||||
| `confidence` | string | Comparison against a number 0 to 100 representing how confident MHS is in the prediction | `>= 90` |
|
||||
| `testOn` | array | Which parts of the Activity to send to MHS. Options: `title` and/or `body` | `body` |
|
||||
|
||||
Example
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- kind: mhs
|
||||
criteria:
|
||||
flagged: true # triggers if MHs flags the content as toxic AND
|
||||
confidence: '> 66' # MHS is 66% or more confident in its prediction
|
||||
testOn: # send the body of the activity to the MHS prediction service
|
||||
- body
|
||||
```
|
||||
|
||||
#### Historical Matching
|
||||
|
||||
Like the [Sentiment](/docs/subreddit/components/sentiment#historical) and [Regex](/docs/subreddit/components/regex#historical) rules CM can also use MHS predictions to check content from the Author's history.
|
||||
|
||||
Example
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- kind: mhs
|
||||
# ...same config as above but can include below...
|
||||
historical:
|
||||
mustMatchCurrent: true # if true then CM will not check author's history unless current Activity matches MHS prediction criteria
|
||||
totalMatching: '> 1' # comparison for how many activities in history must match to trigger the rule
|
||||
window: 10 # specify the range of activities to check in author's history
|
||||
criteria: #... if specified, overrides parent-level criteria
|
||||
```
|
||||
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
|
||||
| Name | Description | Example |
|
||||
|-----------------|-------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | Current Activity MHS Test: ✓ Confidence test (>= 90) PASSED MHS confidence of 99.85% Flagged pass condition of true (toxic) MATCHED MHS flag 'toxic' |
|
||||
| `window` | Number or duration of Activities considered from window | 1 activities |
|
||||
| `criteriaTest` | MHS value to test against | MHS confidence is > 95% |
|
||||
| `totalMatching` | Total number of activities (current + historical) that matched `criteriaTest` | 1 |
|
||||
|
||||
# Examples
|
||||
|
||||
Report if MHS flags as toxic
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- kind: mhs
|
||||
actions:
|
||||
- kind: report
|
||||
content: 'MHS flagged => {{rules.mhs.summary}}'
|
||||
```
|
||||
|
||||
Report if MHS flags as toxic with 95% confidence
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- kind: mhs
|
||||
criteria:
|
||||
confidence: '>= 95'
|
||||
actions:
|
||||
- kind: report
|
||||
content: 'MHS flagged => {{rules.mhs.summary}}'
|
||||
```
|
||||
|
||||
Report if MHS flags as toxic and at least 3 recent activities in last 10 from author's history are also toxic
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- kind: mhs
|
||||
historical:
|
||||
window: 10
|
||||
mustMatchCurrent: true
|
||||
totalMatching: '>= 3'
|
||||
actions:
|
||||
- kind: report
|
||||
content: 'MHS flagged => {{rules.mhs.summary}}'
|
||||
```
|
||||
|
||||
Approve if MHS flags as NOT toxic with 95% confidence
|
||||
|
||||
```yaml
|
||||
rules:
|
||||
- kind: mhs
|
||||
criteria:
|
||||
confidence: '>= 95'
|
||||
flagged: false
|
||||
actions:
|
||||
- kind: approve
|
||||
```
|
||||
@@ -27,5 +27,19 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRecentActi
|
||||
|
||||
### Examples
|
||||
|
||||
* Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits
|
||||
* Submission in Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently
|
||||
* Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits
|
||||
* Submission in Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently
|
||||
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
| Name | Description | Example |
|
||||
|----------------------|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | 9 activities found in 2 of the specified subreddits (out of 21 total) MET threshold of >= 1 activities -- subreddits: SubredditA, SubredditB |
|
||||
| `window` | Number or duration of Activities considered from window | 100 activities |
|
||||
| `subSummary` | Comma-delimited list of subreddits matched by the criteria | SubredditA, SubredditB |
|
||||
| `subCount` | Number of subreddits that match the criteria | 2 |
|
||||
| `totalCount` | Total number of activities found by criteria | 9 |
|
||||
| `threshold` | The threshold used to trigger the rule | `>= 1` |
|
||||
| `karmaThreshold` | If present, the karma threshold used to trigger the rule | `> 5` |
|
||||
| `combinedKarma` | Total number of karma gained from the matched activities | 10 |
|
||||
| `subredditBreakdown` | A markdown list of filtered activities by subreddit | * SubredditA - 5 (71%) \n * Subreddit B - 2 (28%) |
|
||||
|
||||
@@ -11,12 +11,19 @@ Which can then be used in conjunction with a [`window`](https://github.com/FoxxM
|
||||
|
||||
### Examples
|
||||
|
||||
* Trigger if regex matches against the current activity - [YAML](/docs/subreddit/componentscomponents/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchAnyCurrentActivity.json5)
|
||||
* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.json5)
|
||||
* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.json5)
|
||||
* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.json5)
|
||||
* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5)
|
||||
* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.json5)
|
||||
* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.json5)
|
||||
* Remove comments that are spamming discord links - [YAML](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.json5)
|
||||
* Trigger if regex matches against the current activity - [YAML](/docs/subreddit/components/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchAnyCurrentActivity.json5)
|
||||
* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/components/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchThresholdCurrentActivity.json5)
|
||||
* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/components/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/components/regex/matchSubmissionParts.json5)
|
||||
* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchHistoryActivity.json5)
|
||||
* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5)
|
||||
* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchTotalHistoryActivity.json5)
|
||||
* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/components/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchSubsetHistoryActivity.json5)
|
||||
* Remove comments that are spamming discord links - [YAML](/docs/subreddit/components/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/components/regex/removeDiscordSpam.json5)
|
||||
* Differs from just using automod because this config can allow one-off/organic links from users who DO NOT spam discord links but will still remove the comment if the user is spamming them
|
||||
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
| Name | Description | Example |
|
||||
|---------------|---------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | Criteria 1 ✓ -- Activity Match ✓ => 1 > 0 (Threshold > 0) and 1 Total Matches (Window: 1 Item) -- Matched Values: "example.com/test" |
|
||||
| `matchSample` | A comma-delimited list of matches from activities | "example.com/test" |
|
||||
|
||||
@@ -47,5 +47,16 @@ With only `gapAllowance: 2` this rule **would trigger** because the the 1 and 2
|
||||
|
||||
## Examples
|
||||
|
||||
* Crosspost Spamming [JSON](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits
|
||||
* Burst-posting [JSON](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts
|
||||
* Crosspost Spamming [JSON](/docs/subreddit/components/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/components/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits
|
||||
* Burst-posting [JSON](/docs/subreddit/components/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/components/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts
|
||||
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
| Name | Description | Example |
|
||||
|-----------------------|---------------------------------------------------------|-------------------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | 1 of 1 unique items repeated >= 7 times, largest repeat: 22 |
|
||||
| `window` | Number or duration of Activities considered from window | 100 activities |
|
||||
| `threshold` | Number of repeats that trigger rule | `>= 7` |
|
||||
| `totalTriggeringSets` | Number of sets of repeats that matched threshold | 1 |
|
||||
| `largestRepeat` | The largest number of repeats in a single set | 22 |
|
||||
| `gapAllowance` | Number of non-repeat activities allowed between repeats | 2 |
|
||||
|
||||
@@ -267,6 +267,15 @@ When the rule is run in a **Comment Check** you may specify text comparisons (li
|
||||
* **minWordCount** -- The minimum number of words a comment must have
|
||||
* **caseSensitive** -- If the match comparison should be case-sensitive (defaults to `false`)
|
||||
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
|
||||
| Name | Description | Example |
|
||||
|-------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | Searched top 0 comments in top 10 most popular submissions, 50 Youtube comments and found 2 reposts. --- Closest Match => >> The thought of this is terrifying << from Youtube (https://youtube.com/watch?v=example) with 100.00% sameness. |
|
||||
| `closestSummary` | If rule was triggered, the reposted activity type and where it came from | matched a comment from youtube |
|
||||
| `closestSameness` | If rule was triggered, the sameness of repost to the current activity | 100% |
|
||||
|
||||
# Examples
|
||||
|
||||
Examples of a *full* CM configuration, including the Repost Rule, in various scenarios. In each scenario the parts of the configuration that affect the rule are indicated.
|
||||
|
||||
@@ -181,3 +181,16 @@ rules:
|
||||
actions:
|
||||
- kind: remove
|
||||
```
|
||||
|
||||
# [Template Variables](/docs/subreddit/actionTemplating.md)
|
||||
|
||||
| Name | Description | Example |
|
||||
|---------------------------|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
|
||||
| `result` | Summary of rule results (also found in Actioned Events) | Current Activity Sentiment -0.35 (-0.45) PASSED sentiment test < -0.3 |
|
||||
| `triggered` | Boolean if rule was triggered or not | true |
|
||||
| `sentimentTest` | The sentiment value test | `< -0.3` |
|
||||
| `historicalSentimentTest` | The sentiment value test used for historical activities | `< -0.3` |
|
||||
| `averageScore` | The averaged score (equal weights) for all sentiment analysis tests run on the current activity | -0.35 |
|
||||
| `averageWindowScore` | The averaged score (equal weights) for all sentiment analysis tests run on historical activities | -0.35 |
|
||||
| `window` | Number or duration of Activities considered from window | 100 activities |
|
||||
| `totalMatching` | Number of activities that passed the sentimentTest | 1 |
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
Provided here are **complete, ready-to-go configuration** that can copy-pasted straight into your configuration wiki page to get going with ContextMod immediately.
|
||||
|
||||
These configurations attempt to provide sensible, non-destructive, default behavior for some common scenarios and subreddit types.
|
||||
|
||||
In most cases these will perform decently out-of-the-box but they are not perfect. You should still monitor bot behavior to see how it performs and will most likely still need to tweak these configurations to get your desired behavior.
|
||||
|
||||
All actions for these configurations are non-destructive in that:
|
||||
|
||||
* All instances where an activity would be modified (remove/ban/approve) will have `dryRun: true` set to prevent the action from actually being performed
|
||||
* These instances will also have a `report` action detailing the action would have been performed
|
||||
|
||||
**You will have to remove the `report` action and `dryRun` settings yourself.** This is to ensure that you understand the behavior the bot will be performing. If you are unsure of this you should leave them in place until you are certain the behavior the bot is performing is acceptable.
|
||||
|
||||
**YAML** is the same format as **automoderator**
|
||||
|
||||
## Submission-based Behavior
|
||||
|
||||
### Remove submissions from users who have used 'freekarma' subs to bypass karma checks
|
||||
|
||||
[YAML](/docs/subreddit/componentscomponents/subredditReady/freekarma.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/freekarma.json5)
|
||||
|
||||
If the user has any activity (comment/submission) in known freekarma subreddits in the past (50 activities or 6 months) then remove the submission.
|
||||
|
||||
### Remove submissions from users who have crossposted the same submission 4 or more times
|
||||
|
||||
[YAML](/docs/subreddit/componentscomponents/subredditReady/crosspostSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/crosspostSpam.yaml)
|
||||
|
||||
If the user has crossposted the same submission in the past (50 activities or 6 months) 4 or more times in a row then remove the submission.
|
||||
|
||||
### Remove submissions from users who have crossposted or used 'freekarma' subs
|
||||
|
||||
[YAML](/docs/subreddit/componentscomponents/subredditReady/freeKarmaOrCrosspostSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/freeKarmaOrCrosspostSpam.json5)
|
||||
|
||||
Will remove submission if either of the above two behaviors is detected
|
||||
|
||||
### Remove link submissions where the user's history is comprised of 10% or more of the same link
|
||||
|
||||
[YAML](/docs/subreddit/componentscomponents/subredditReady/selfPromo.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/selfPromo.json5)
|
||||
|
||||
If the link origin (youtube author, twitter author, etc. or regular domain for non-media links)
|
||||
|
||||
* comprises 10% or more of the users **entire** history in the past (100 activities or 6 months)
|
||||
* or comprises 10% or more of the users **submission** history in the past (100 activities or 6 months) and the user has low engagement (<50% of history is comments or 40%> of comment are as OP)
|
||||
|
||||
then remove the submission
|
||||
|
||||
## Comment-based behavior
|
||||
|
||||
### Remove comment if the user has posted the same comment 4 or more times in a row
|
||||
|
||||
[YAML](/docs/subreddit/componentscomponents/subredditReady/commentSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/commentSpam.json5)
|
||||
|
||||
If the user made the same comment (with some fuzzy matching) 4 or more times in a row in the past (50 activities or 6 months) then remove the comment.
|
||||
|
||||
### Remove comment if it is discord invite link spam
|
||||
|
||||
[YAML](/docs/subreddit/componentscomponents/subredditReady/discordSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/discordSpam.json5)
|
||||
|
||||
This rule goes a step further than automod can by being more discretionary about how it handles this type of spam.
|
||||
|
||||
* Remove the comment and **ban a user** if:
|
||||
* Comment being checked contains **only** a discord link (no other text) AND
|
||||
* Discord links appear **anywhere** in three or more of the last 10 comments the Author has made
|
||||
|
||||
otherwise...
|
||||
|
||||
* Remove the comment if:
|
||||
* Comment being checked contains **only** a discord link (no other text) OR
|
||||
* Comment contains a discord link **anywhere** AND
|
||||
* Discord links appear **anywhere** in three or more of the last 10 comments the Author has made
|
||||
|
||||
Using these checks ContextMod can more easily distinguish between these use cases for a user commenting with a discord link:
|
||||
|
||||
* actual spammers who only spam a discord link
|
||||
* users who may comment with a link but have context for it either in the current comment or in their history
|
||||
* users who many comment with a link but it's a one-off event (no other links historically)
|
||||
|
||||
Additionally, you could modify both/either of these checks to not remove one-off discord link comments but still remove if the user has a historical trend for spamming links
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"polling": ["newComm"],
|
||||
"runs": [
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
//
|
||||
// Stop users who spam the same comment many times
|
||||
//
|
||||
// Remove a COMMENT if the user has crossposted it at least 4 times in recent history
|
||||
//
|
||||
"name": "low xp comment spam",
|
||||
"description": "X-posted comment >=4x",
|
||||
"kind": "comment",
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"name": "xPostLow",
|
||||
"kind": "repeatActivity",
|
||||
"gapAllowance": 2,
|
||||
"threshold": ">= 4",
|
||||
"window": {
|
||||
"count": 50,
|
||||
"duration": "6 months"
|
||||
}
|
||||
},
|
||||
],
|
||||
"actions": [
|
||||
// remove this after confirming behavior is acceptable
|
||||
{
|
||||
"kind": "report",
|
||||
"content": "Remove=> Posted same comment {{rules.xpostlow.largestRepeat}}x times"
|
||||
},
|
||||
//
|
||||
//
|
||||
{
|
||||
"kind": "remove",
|
||||
// remove the line below after confirming behavior is acceptable
|
||||
"dryRun": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
{
|
||||
"polling": ["unmoderated"],
|
||||
"runs": [
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
//
|
||||
// Stop users who post low-effort, crossposted spam
|
||||
//
|
||||
// Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
|
||||
// less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
|
||||
//
|
||||
"name": "low xp spam and engagement",
|
||||
"description": "X-posted 4x and low comment engagement",
|
||||
"kind": "submission",
|
||||
"itemIs": [
|
||||
{
|
||||
"removed": false
|
||||
}
|
||||
],
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"name": "xPostLow",
|
||||
"kind": "repeatActivity",
|
||||
"gapAllowance": 2,
|
||||
"threshold": ">= 4",
|
||||
"window": {
|
||||
"count": 50,
|
||||
"duration": "6 months"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lowOrOpComm",
|
||||
"kind": "history",
|
||||
"criteriaJoin": "OR",
|
||||
"criteria": [
|
||||
{
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"comment": "< 50%"
|
||||
},
|
||||
{
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"comment": "> 40% OP"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
// remove this after confirming behavior is acceptable
|
||||
{
|
||||
"kind": "report",
|
||||
"content": "Remove=>{{rules.xpostlow.largestRepeat}} X-P => {{rules.loworopcomm.thresholdSummary}}"
|
||||
},
|
||||
//
|
||||
//
|
||||
{
|
||||
"kind": "remove",
|
||||
// remove the line below after confirming behavior is acceptable
|
||||
"dryRun": true
|
||||
},
|
||||
// optionally remove "dryRun" from below if you want to leave a comment on removal
|
||||
// PROTIP: the comment is bland, you should make it better
|
||||
{
|
||||
"kind": "comment",
|
||||
"content": "Your submission has been removed because you cross-posted it {{rules.xpostlow.largestRepeat}} times and you have very low engagement outside of making submissions",
|
||||
"distinguish": true,
|
||||
"dryRun": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
{
|
||||
"polling": ["newComm"],
|
||||
"runs": [
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
"name": "ban discord only spammer",
|
||||
"description": "ban a user who spams only a discord link many times historically",
|
||||
"kind": "comment",
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
"linkOnlySpam",
|
||||
"linkAnywhereHistoricalSpam",
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"kind": "remove"
|
||||
},
|
||||
{
|
||||
"kind": "ban",
|
||||
"content": "spamming discord links"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "remove discord spam",
|
||||
"description": "remove comments from users who only link to discord or mention discord link many times historically",
|
||||
"kind": "comment",
|
||||
"condition": "OR",
|
||||
"rules": [
|
||||
{
|
||||
"name": "linkOnlySpam",
|
||||
"kind": "regex",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "only link",
|
||||
"regex": "/^.*(discord\\.gg\\/[\\w\\d]+)$/i",
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"name": "linkAnywhereSpam",
|
||||
"kind": "regex",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "contains link anywhere",
|
||||
"regex": "/^.*(discord\\.gg\\/[\\w\\d]+).*$/i",
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "linkAnywhereHistoricalSpam",
|
||||
"kind": "regex",
|
||||
"criteria": [
|
||||
{
|
||||
"name": "contains links anywhere historically",
|
||||
"regex": "/^.*(discord\\.gg\\/[\\w\\d]+).*$/i",
|
||||
"totalMatchThreshold": ">= 3",
|
||||
"lookAt": "comments",
|
||||
"window": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"kind": "remove"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
{
|
||||
"polling": [
|
||||
"unmoderated"
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
//
|
||||
// Stop users who post low-effort, crossposted spam
|
||||
//
|
||||
// Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
|
||||
// less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
|
||||
//
|
||||
"name": "remove on low xp spam and engagement",
|
||||
"description": "X-posted 4x and low comment engagement",
|
||||
"kind": "submission",
|
||||
"itemIs": [
|
||||
{
|
||||
"removed": false
|
||||
}
|
||||
],
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"name": "xPostLow",
|
||||
"kind": "repeatActivity",
|
||||
"gapAllowance": 2,
|
||||
"threshold": ">= 4",
|
||||
"window": {
|
||||
"count": 50,
|
||||
"duration": "6 months"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "lowOrOpComm",
|
||||
"kind": "history",
|
||||
"criteriaJoin": "OR",
|
||||
"criteria": [
|
||||
{
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"comment": "< 50%"
|
||||
},
|
||||
{
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"comment": "> 40% OP"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
// remove this after confirming behavior is acceptable
|
||||
{
|
||||
"kind": "report",
|
||||
"content": "Remove=>{{rules.xpostlow.largestRepeat}} X-P => {{rules.loworopcomm.thresholdSummary}}"
|
||||
},
|
||||
//
|
||||
//
|
||||
{
|
||||
"kind": "remove",
|
||||
// remove the line below after confirming behavior is acceptable
|
||||
"dryRun": true
|
||||
},
|
||||
// optionally remove "dryRun" from below if you want to leave a comment on removal
|
||||
// PROTIP: the comment is bland, you should make it better
|
||||
{
|
||||
"kind": "comment",
|
||||
"content": "Your submission has been removed because you cross-posted it {{rules.xpostlow.largestRepeat}} times and you have very low engagement outside of making submissions",
|
||||
"distinguish": true,
|
||||
"dryRun": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
//
|
||||
// Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
|
||||
//
|
||||
"name": "freekarma removal",
|
||||
"description": "Remove submission if user has used freekarma sub recently",
|
||||
"kind": "submission",
|
||||
"itemIs": [
|
||||
{
|
||||
"removed": false
|
||||
}
|
||||
],
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"name": "freekarma",
|
||||
"kind": "recentActivity",
|
||||
"window": {
|
||||
"count": 50,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"useSubmissionAsReference": false,
|
||||
"thresholds": [
|
||||
{
|
||||
"subreddits": [
|
||||
"FreeKarma4U",
|
||||
"FreeKarma4You",
|
||||
"KarmaStore",
|
||||
"promote",
|
||||
"shamelessplug",
|
||||
"upvote"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
// remove this after confirming behavior is acceptable
|
||||
{
|
||||
"kind": "report",
|
||||
"content": "Remove=> {{rules.newtube.totalCount}} activities in freekarma subs"
|
||||
},
|
||||
//
|
||||
//
|
||||
{
|
||||
"kind": "remove",
|
||||
// remove the line below after confirming behavior is acceptable
|
||||
"dryRun": true
|
||||
},
|
||||
// optionally remove "dryRun" from below if you want to leave a comment on removal
|
||||
// PROTIP: the comment is bland, you should make it better
|
||||
{
|
||||
"kind": "comment",
|
||||
"content": "Your submission has been removed because you have recent activity in 'freekarma' subs",
|
||||
"distinguish": true,
|
||||
"dryRun": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
polling:
|
||||
- unmoderated
|
||||
runs:
|
||||
- checks:
|
||||
# stop users who post low-effort, crossposted spam submissions
|
||||
#
|
||||
# Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
|
||||
# less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
|
||||
- name: remove on low xp spam and engagement
|
||||
description: X-posted 4x and low comment engagement
|
||||
kind: submission
|
||||
itemIs:
|
||||
- removed: false
|
||||
condition: AND
|
||||
rules:
|
||||
- name: xPostLow
|
||||
kind: repeatActivity
|
||||
gapAllowance: 2
|
||||
threshold: '>= 4'
|
||||
window:
|
||||
count: 50
|
||||
duration: 6 months
|
||||
- name: lowOrOpComm
|
||||
kind: history
|
||||
criteriaJoin: OR
|
||||
criteria:
|
||||
- window:
|
||||
count: 100
|
||||
duration: 6 months
|
||||
comment: < 50%
|
||||
- window:
|
||||
count: 100
|
||||
duration: 6 months
|
||||
comment: '> 40% OP'
|
||||
actions:
|
||||
- kind: report
|
||||
enable: true
|
||||
content: >-
|
||||
Remove=>{{rules.xpostlow.largestRepeat}} X-P =>
|
||||
{{rules.loworopcomm.thresholdSummary}}
|
||||
- kind: remove
|
||||
enable: false
|
||||
- kind: comment
|
||||
enable: true
|
||||
content: >-
|
||||
Your submission has been removed because you cross-posted it
|
||||
{{rules.xpostlow.largestRepeat}} times and you have very low
|
||||
engagement outside of making submissions
|
||||
distinguish: true
|
||||
dryRun: true
|
||||
# Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
|
||||
- name: freekarma removal
|
||||
description: Remove submission if user has used freekarma sub recently
|
||||
kind: submission
|
||||
itemIs:
|
||||
- removed: false
|
||||
condition: AND
|
||||
rules:
|
||||
- name: freekarma
|
||||
kind: recentActivity
|
||||
window:
|
||||
count: 50
|
||||
duration: 6 months
|
||||
useSubmissionAsReference: false
|
||||
thresholds:
|
||||
- subreddits:
|
||||
- FreeKarma4U
|
||||
- FreeKarma4You
|
||||
- KarmaStore
|
||||
- promote
|
||||
- shamelessplug
|
||||
- upvote
|
||||
actions:
|
||||
- kind: report
|
||||
enable: true
|
||||
content: 'Remove=> {{rules.newtube.totalCount}} activities in freekarma subs'
|
||||
- kind: remove
|
||||
enable: false
|
||||
- kind: comment
|
||||
enable: true
|
||||
content: >-
|
||||
Your submission has been removed because you have recent activity in
|
||||
'freekarma' subs
|
||||
distinguish: true
|
||||
dryRun: true
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"polling": [
|
||||
"unmoderated"
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
//
|
||||
// Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
|
||||
//
|
||||
"name": "freekarma removal",
|
||||
"description": "Remove submission if user has used freekarma sub recently",
|
||||
"kind": "submission",
|
||||
"itemIs": [
|
||||
{
|
||||
"removed": false
|
||||
}
|
||||
],
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"name": "freekarma",
|
||||
"kind": "recentActivity",
|
||||
"window": {
|
||||
"count": 50,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"useSubmissionAsReference": false,
|
||||
"thresholds": [
|
||||
{
|
||||
"subreddits": [
|
||||
"FreeKarma4U",
|
||||
"FreeKarma4You",
|
||||
"KarmaStore",
|
||||
"upvote"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
// remove this after confirming behavior is acceptable
|
||||
{
|
||||
"kind": "report",
|
||||
"content": "Remove=> {{rules.newtube.totalCount}} activities in freekarma subs"
|
||||
},
|
||||
//
|
||||
//
|
||||
{
|
||||
"kind": "remove",
|
||||
// remove the line below after confirming behavior is acceptable
|
||||
"dryRun": true,
|
||||
},
|
||||
// optionally remove "dryRun" from below if you want to leave a comment on removal
|
||||
// PROTIP: the comment is bland, you should make it better
|
||||
{
|
||||
"kind": "comment",
|
||||
"content": "Your submission has been removed because you have recent activity in 'freekarma' subs",
|
||||
"distinguish": true,
|
||||
"dryRun": true,
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
polling:
|
||||
- unmoderated
|
||||
runs:
|
||||
- checks:
|
||||
# Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
|
||||
- name: freekarma removal
|
||||
description: Remove submission if user has used freekarma sub recently
|
||||
kind: submission
|
||||
itemIs:
|
||||
- removed: false
|
||||
condition: AND
|
||||
rules:
|
||||
- name: freekarma
|
||||
kind: recentActivity
|
||||
window:
|
||||
count: 50
|
||||
duration: 6 months
|
||||
useSubmissionAsReference: false
|
||||
thresholds:
|
||||
- subreddits:
|
||||
- FreeKarma4U
|
||||
- FreeKarma4You
|
||||
- KarmaStore
|
||||
- upvote
|
||||
actions:
|
||||
- kind: report
|
||||
enable: true
|
||||
content: 'Remove=> {{rules.newtube.totalCount}} activities in freekarma subs'
|
||||
- kind: remove
|
||||
enable: true
|
||||
- kind: comment
|
||||
enable: false
|
||||
content: >-
|
||||
Your submission has been removed because you have recent activity in
|
||||
'freekarma' subs
|
||||
distinguish: true
|
||||
@@ -1,108 +0,0 @@
|
||||
{
|
||||
"polling": [
|
||||
"unmoderated"
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"checks": [
|
||||
{
|
||||
//
|
||||
// Stop users who make link submissions with a self-promotional agenda (with reddit's suggested 10% rule)
|
||||
// https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit
|
||||
//
|
||||
// Remove a SUBMISSION if the link comprises more than or equal to 10% of users history (100 activities or 6 months) OR
|
||||
//
|
||||
// if link comprises 10% of submission history (100 activities or 6 months)
|
||||
// AND less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
|
||||
//
|
||||
"name": "Self-promo all AND low engagement",
|
||||
"description": "Self-promo is >10% for all or just sub and low comment engagement",
|
||||
"kind": "submission",
|
||||
"condition": "OR",
|
||||
"rules": [
|
||||
{
|
||||
"name": "attr",
|
||||
"kind": "attribution",
|
||||
"criteria": [
|
||||
{
|
||||
"threshold": ">= 10%",
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"domains": [
|
||||
"AGG:SELF"
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
{
|
||||
"condition": "AND",
|
||||
"rules": [
|
||||
{
|
||||
"name": "attrsub",
|
||||
"kind": "attribution",
|
||||
"criteria": [
|
||||
{
|
||||
"threshold": ">= 10%",
|
||||
"thresholdOn": "submissions",
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"domains": [
|
||||
"AGG:SELF"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "lowOrOpComm",
|
||||
"kind": "history",
|
||||
"criteriaJoin": "OR",
|
||||
"criteria": [
|
||||
{
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"comment": "< 50%"
|
||||
},
|
||||
{
|
||||
"window": {
|
||||
"count": 100,
|
||||
"duration": "6 months"
|
||||
},
|
||||
"comment": "> 40% OP"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"kind": "report",
|
||||
"content": "{{rules.attr.largestPercent}}{{rules.attrsub.largestPercent}} of {{rules.attr.activityTotal}}{{rules.attrsub.activityTotal}} items ({{rules.attr.window}}{{rules.attrsub.window}}){{#rules.loworopcomm.thresholdSummary}} => {{rules.loworopcomm.thresholdSummary}}{{/rules.loworopcomm.thresholdSummary}}"
|
||||
},
|
||||
//
|
||||
//
|
||||
{
|
||||
"kind": "remove",
|
||||
// remove the line below after confirming behavior is acceptable
|
||||
"dryRun": true
|
||||
},
|
||||
// optionally remove "dryRun" from below if you want to leave a comment on removal
|
||||
// PROTIP: the comment is bland, you should make it better
|
||||
{
|
||||
"kind": "comment",
|
||||
"content": "Your submission has been removed it comprises 10% or more of your recent history ({{rules.attr.largestPercent}}{{rules.attrsub.largestPercent}}). This is against [reddit's self promotional guidelines.](https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit)",
|
||||
"distinguish": true,
|
||||
"dryRun": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -24,7 +24,7 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteCr
|
||||
|
||||
### Examples
|
||||
|
||||
* Do not tag user with Good User note [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.yaml)
|
||||
* Do not tag user with Good User note [JSON](/docs/subreddit/components/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteFilter.yaml)
|
||||
|
||||
## Action
|
||||
|
||||
@@ -33,4 +33,4 @@ A User Note can also be added to the Author of a Submission or Comment with the
|
||||
|
||||
### Examples
|
||||
|
||||
* Add note on user doing self promotion [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteSP.yaml)
|
||||
* Add note on user doing self promotion [JSON](/docs/subreddit/components/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteSP.yaml)
|
||||
|
||||
@@ -104,7 +104,7 @@ If you already have a configuration you may skip the below step and go directly
|
||||
|
||||
### Using an Example Config
|
||||
|
||||
Visit the [Examples](https://github.com/FoxxMD/context-mod/tree/master/docs/examples) folder to find various examples of individual rules or see the [subreddit-ready examples.](/docs/subreddit/components/subredditReady)
|
||||
Visit the [Examples](https://github.com/FoxxMD/context-mod/tree/master/docs/examples) folder to find various examples of individual rules or see the [subreddit cookbook examples.](/docs/subreddit/components/cookbook)
|
||||
|
||||
After you have found a configuration to use as a starting point:
|
||||
|
||||
|
||||
70
package-lock.json
generated
@@ -11,9 +11,10 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.8.0",
|
||||
"@datasert/cronjs-matcher": "^1.2.0",
|
||||
"@googleapis/youtube": "^2.0.0",
|
||||
"@influxdata/influxdb-client": "^1.27.0",
|
||||
"@influxdata/influxdb-client-apis": "^1.27.0",
|
||||
"@influxdata/influxdb-client": "^1.31.0",
|
||||
"@influxdata/influxdb-client-apis": "^1.31.0",
|
||||
"@nlpjs/core": "^4.23.4",
|
||||
"@nlpjs/lang-de": "^4.23.4",
|
||||
"@nlpjs/lang-en": "^4.23.4",
|
||||
@@ -656,6 +657,20 @@
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@datasert/cronjs-matcher": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@datasert/cronjs-matcher/-/cronjs-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-ht6Vwwa3qssMn/9bphypjG/U8w0DV3GtTS2C6kbAy39rerQFTRzmml9xZNlot1K13gm9K/EEq3DLPEOsH++ICw==",
|
||||
"dependencies": {
|
||||
"@datasert/cronjs-parser": "^1.2.0",
|
||||
"luxon": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@datasert/cronjs-parser": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@datasert/cronjs-parser/-/cronjs-parser-1.2.0.tgz",
|
||||
"integrity": "sha512-7kzYh7F5V3ElX+k3W9w6SKS6WdjqJQ2gIY1y0evldnjAwZxnFzR/Yu9Mv9OeDaCQX+mGAq2MvEnJbwu9oj3CXQ=="
|
||||
},
|
||||
"node_modules/@googleapis/youtube": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/youtube/-/youtube-2.0.0.tgz",
|
||||
@@ -668,14 +683,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@influxdata/influxdb-client": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.27.0.tgz",
|
||||
"integrity": "sha512-hOBi+ApIurDd8jFWo+eYjMWWsDRp3wih/U/NOVRoHaTOE8ihSQthi9wfMD4YeVqt4pCN6ygIwo7lEKFXwNuwcA=="
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz",
|
||||
"integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA=="
|
||||
},
|
||||
"node_modules/@influxdata/influxdb-client-apis": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.27.0.tgz",
|
||||
"integrity": "sha512-a4gd7CwNRXSsSVt9tm8GzGxuPXngEmQucMdoTZ0YYeWSbKUXz3B/3u9/EqMGEbtq5MdbbB2OKA611hu205UiNg==",
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz",
|
||||
"integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==",
|
||||
"peerDependencies": {
|
||||
"@influxdata/influxdb-client": "*"
|
||||
}
|
||||
@@ -5796,6 +5811,14 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/luxon": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.0.tgz",
|
||||
"integrity": "sha512-IDkEPB80Rb6gCAU+FEib0t4FeJ4uVOuX1CQ9GsvU3O+JAGIgu0J7sf1OarXKaKDygTZIoJyU6YdZzTFRu+YR0A==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
@@ -10812,6 +10835,20 @@
|
||||
"kuler": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@datasert/cronjs-matcher": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@datasert/cronjs-matcher/-/cronjs-matcher-1.2.0.tgz",
|
||||
"integrity": "sha512-ht6Vwwa3qssMn/9bphypjG/U8w0DV3GtTS2C6kbAy39rerQFTRzmml9xZNlot1K13gm9K/EEq3DLPEOsH++ICw==",
|
||||
"requires": {
|
||||
"@datasert/cronjs-parser": "^1.2.0",
|
||||
"luxon": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"@datasert/cronjs-parser": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@datasert/cronjs-parser/-/cronjs-parser-1.2.0.tgz",
|
||||
"integrity": "sha512-7kzYh7F5V3ElX+k3W9w6SKS6WdjqJQ2gIY1y0evldnjAwZxnFzR/Yu9Mv9OeDaCQX+mGAq2MvEnJbwu9oj3CXQ=="
|
||||
},
|
||||
"@googleapis/youtube": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@googleapis/youtube/-/youtube-2.0.0.tgz",
|
||||
@@ -10821,14 +10858,14 @@
|
||||
}
|
||||
},
|
||||
"@influxdata/influxdb-client": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.27.0.tgz",
|
||||
"integrity": "sha512-hOBi+ApIurDd8jFWo+eYjMWWsDRp3wih/U/NOVRoHaTOE8ihSQthi9wfMD4YeVqt4pCN6ygIwo7lEKFXwNuwcA=="
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz",
|
||||
"integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA=="
|
||||
},
|
||||
"@influxdata/influxdb-client-apis": {
|
||||
"version": "1.27.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.27.0.tgz",
|
||||
"integrity": "sha512-a4gd7CwNRXSsSVt9tm8GzGxuPXngEmQucMdoTZ0YYeWSbKUXz3B/3u9/EqMGEbtq5MdbbB2OKA611hu205UiNg==",
|
||||
"version": "1.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz",
|
||||
"integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==",
|
||||
"requires": {}
|
||||
},
|
||||
"@istanbuljs/load-nyc-config": {
|
||||
@@ -14886,6 +14923,11 @@
|
||||
"yallist": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"luxon": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.0.tgz",
|
||||
"integrity": "sha512-IDkEPB80Rb6gCAU+FEib0t4FeJ4uVOuX1CQ9GsvU3O+JAGIgu0J7sf1OarXKaKDygTZIoJyU6YdZzTFRu+YR0A=="
|
||||
},
|
||||
"make-dir": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
|
||||
|
||||
@@ -31,9 +31,10 @@
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@awaitjs/express": "^0.8.0",
|
||||
"@datasert/cronjs-matcher": "^1.2.0",
|
||||
"@googleapis/youtube": "^2.0.0",
|
||||
"@influxdata/influxdb-client": "^1.27.0",
|
||||
"@influxdata/influxdb-client-apis": "^1.27.0",
|
||||
"@influxdata/influxdb-client": "^1.31.0",
|
||||
"@influxdata/influxdb-client-apis": "^1.31.0",
|
||||
"@nlpjs/core": "^4.23.4",
|
||||
"@nlpjs/lang-de": "^4.23.4",
|
||||
"@nlpjs/lang-en": "^4.23.4",
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import Action, {ActionJson, ActionOptions} from "./index";
|
||||
import {Comment, VoteableContent} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {activityIsRemoved, renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {ActionProcessResult, Footer, RequiredRichContent, RichContent, RuleResult} from "../Common/interfaces";
|
||||
import {asComment, asSubmission, parseRedditThingsFromLink, truncateStringToLength} from "../util";
|
||||
import {
|
||||
asComment,
|
||||
asSubmission,
|
||||
getActivitySubredditName,
|
||||
parseRedditThingsFromLink,
|
||||
truncateStringToLength
|
||||
} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infrastructure/Atomic";
|
||||
@@ -18,6 +24,7 @@ export class CommentAction extends Action {
|
||||
distinguish: boolean = false;
|
||||
footer?: false | string;
|
||||
targets: ArbitraryActionTarget[]
|
||||
asModTeam: boolean;
|
||||
|
||||
constructor(options: CommentActionOptions) {
|
||||
super(options);
|
||||
@@ -27,12 +34,14 @@ export class CommentAction extends Action {
|
||||
sticky = false,
|
||||
distinguish = false,
|
||||
footer,
|
||||
targets = ['self']
|
||||
targets = ['self'],
|
||||
asModTeam = false,
|
||||
} = options;
|
||||
this.footer = footer;
|
||||
this.content = content;
|
||||
this.lock = lock;
|
||||
this.sticky = sticky;
|
||||
this.asModTeam = asModTeam;
|
||||
this.distinguish = distinguish;
|
||||
if (!Array.isArray(targets)) {
|
||||
this.targets = [targets];
|
||||
@@ -104,17 +113,48 @@ export class CommentAction extends Action {
|
||||
continue;
|
||||
}
|
||||
|
||||
if(this.asModTeam) {
|
||||
if(!targetItem.can_mod_post) {
|
||||
const noMod = `[${targetIdentifier}] Cannot comment as subreddit because bot is not a moderator`;
|
||||
this.logger.warn(noMod);
|
||||
targetResults.push(noMod);
|
||||
continue;
|
||||
}
|
||||
if(getActivitySubredditName(targetItem) !== this.resources.subreddit.display_name) {
|
||||
const wrongSubreddit = `[${targetIdentifier}] Will not comment as subreddit because Activity did not occur in the same subreddit as the bot is moderating`;
|
||||
this.logger.warn(wrongSubreddit);
|
||||
targetResults.push(wrongSubreddit);
|
||||
continue;
|
||||
}
|
||||
if(!activityIsRemoved(targetItem)) {
|
||||
const notRemoved = `[${targetIdentifier}] Cannot comment as subreddit because Activity IS NOT REMOVED.`
|
||||
this.logger.warn(notRemoved);
|
||||
targetResults.push(notRemoved);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let modifiers = [];
|
||||
let reply: Comment;
|
||||
if (!dryRun) {
|
||||
// @ts-ignore
|
||||
reply = await targetItem.reply(renderedContent);
|
||||
if(this.asModTeam) {
|
||||
try {
|
||||
reply = await this.client.addRemovalMessage(targetItem, renderedContent, 'public_as_subreddit',{lock: this.lock});
|
||||
} catch (e: any) {
|
||||
this.logger.warn(new CMError('Could not comment as subreddit', {cause: e}));
|
||||
targetResults.push(`Could not comment as subreddit: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
// @ts-ignore
|
||||
reply = await targetItem.reply(renderedContent);
|
||||
}
|
||||
// add to recent so we ignore activity when/if it is discovered by polling
|
||||
await this.resources.setRecentSelf(reply);
|
||||
touchedEntities.push(reply);
|
||||
}
|
||||
|
||||
if (this.lock && targetItem.can_mod_post) {
|
||||
if (!this.asModTeam && this.lock && targetItem.can_mod_post) {
|
||||
if (!targetItem.can_mod_post) {
|
||||
this.logger.warn(`[${targetIdentifier}] Cannot lock because bot is not a moderator`);
|
||||
} else {
|
||||
@@ -127,7 +167,7 @@ export class CommentAction extends Action {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.distinguish) {
|
||||
if (!this.asModTeam && this.distinguish) {
|
||||
if (!targetItem.can_mod_post) {
|
||||
this.logger.warn(`[${targetIdentifier}] Cannot lock Distinguish/Sticky because bot is not a moderator`);
|
||||
} else {
|
||||
@@ -203,6 +243,16 @@ export interface CommentActionConfig extends RequiredRichContent, Footer {
|
||||
* If target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity
|
||||
* */
|
||||
targets?: ArbitraryActionTarget | ArbitraryActionTarget[]
|
||||
|
||||
/**
|
||||
* Comment "as subreddit" using the "/u/subreddit-ModTeam" account
|
||||
*
|
||||
* RESTRICTIONS:
|
||||
*
|
||||
* * Target activity must ALREADY BE REMOVED
|
||||
* * Will always distinguish and sticky the created comment
|
||||
* */
|
||||
asModTeam?: boolean
|
||||
}
|
||||
|
||||
export interface CommentActionOptions extends CommentActionConfig, ActionOptions {
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
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 {buildFilterCriteriaSummary, normalizeModActionCriteria, 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";
|
||||
import {
|
||||
ActionTypes,
|
||||
ModUserNoteLabel,
|
||||
} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
import {ModNoteCriteria} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
|
||||
|
||||
export class ModNoteAction extends Action {
|
||||
content: string;
|
||||
type?: string;
|
||||
allowDuplicate: boolean;
|
||||
existingNoteCheck?: ModNoteCriteria
|
||||
referenceActivity: boolean
|
||||
|
||||
constructor(options: ModNoteActionOptions) {
|
||||
super(options);
|
||||
const {type, content = '', allowDuplicate = false, referenceActivity = true} = options;
|
||||
const {type, content = '', existingNoteCheck = true, referenceActivity = true} = options;
|
||||
this.type = type;
|
||||
this.content = content;
|
||||
this.allowDuplicate = allowDuplicate;
|
||||
this.referenceActivity = referenceActivity;
|
||||
this.existingNoteCheck = typeof existingNoteCheck === 'boolean' ? this.generateModLogCriteriaFromDuplicateConvenience(existingNoteCheck) : normalizeModActionCriteria(existingNoteCheck);
|
||||
}
|
||||
|
||||
getKind(): ActionTypes {
|
||||
@@ -35,7 +37,7 @@ export class ModNoteAction extends Action {
|
||||
return {
|
||||
content: this.content,
|
||||
type: this.type,
|
||||
allowDuplicate: this.allowDuplicate,
|
||||
existingNoteCheck: this.existingNoteCheck,
|
||||
referenceActivity: this.referenceActivity,
|
||||
}
|
||||
}
|
||||
@@ -48,27 +50,30 @@ export class ModNoteAction extends Action {
|
||||
const renderedContent = await this.renderContent(this.content, item, ruleResults, actionResults);
|
||||
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`
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
let noteCheckPassed: boolean = true;
|
||||
let noteCheckResult: undefined | string;
|
||||
|
||||
if(this.existingNoteCheck === undefined) {
|
||||
// nothing to do!
|
||||
noteCheckResult = 'existingNoteCheck=false so no existing note checks were performed.';
|
||||
} else {
|
||||
const noteCheckCriteriaResult = await this.resources.isAuthor(item, {
|
||||
modActions: [this.existingNoteCheck]
|
||||
});
|
||||
noteCheckPassed = noteCheckCriteriaResult.passed;
|
||||
const {details} = buildFilterCriteriaSummary(noteCheckCriteriaResult);
|
||||
noteCheckResult = `${noteCheckPassed ? 'Existing note check condition succeeded' : 'Will not add note because existing note check condition failed'} -- ${details.join(' ')}`;
|
||||
}
|
||||
|
||||
this.logger.info(noteCheckResult);
|
||||
if (!noteCheckPassed) {
|
||||
return {
|
||||
dryRun,
|
||||
success: false,
|
||||
result: noteCheckResult
|
||||
};
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await this.resources.addModNote({
|
||||
label: modLabel,
|
||||
@@ -84,15 +89,36 @@ export class ModNoteAction extends Action {
|
||||
result: `${modLabel !== undefined ? `(${modLabel})` : ''} ${renderedContent}`
|
||||
}
|
||||
}
|
||||
|
||||
generateModLogCriteriaFromDuplicateConvenience(val: boolean): ModNoteCriteria | undefined {
|
||||
if(val) {
|
||||
return {
|
||||
noteType: this.type !== undefined ? [toModNoteLabel(this.type)] : undefined,
|
||||
note: this.content !== '' ? [this.content] : undefined,
|
||||
referencesCurrentActivity: this.referenceActivity ? true : undefined,
|
||||
search: 'current',
|
||||
count: '< 1'
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModNoteActionConfig extends ActionConfig, RichContent {
|
||||
/**
|
||||
* Add Note even if a Note already exists for this Activity
|
||||
* @examples [false]
|
||||
* @default false
|
||||
* Check if there is an existing Note matching some criteria before adding the Note.
|
||||
*
|
||||
* If this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.
|
||||
*
|
||||
* Boolean convenience:
|
||||
*
|
||||
* * If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria
|
||||
* * If `false` then no check is performed and Note is always added
|
||||
*
|
||||
* @examples [true]
|
||||
* @default true
|
||||
* */
|
||||
allowDuplicate?: boolean,
|
||||
existingNoteCheck?: boolean | ModNoteCriteria,
|
||||
type?: ModUserNoteLabel
|
||||
referenceActivity?: boolean
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {ActionJson, ActionConfig, ActionOptions} from "./index";
|
||||
import Action from "./index";
|
||||
import {Comment} from "snoowrap";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {UserNoteJson} from "../Subreddit/UserNotes";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {ActionProcessResult, RuleResult} from "../Common/interfaces";
|
||||
@@ -9,19 +8,34 @@ import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes, UserNoteType} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
import {
|
||||
FullUserNoteCriteria,
|
||||
toFullUserNoteCriteria, UserNoteCriteria
|
||||
} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
import {buildFilterCriteriaSummary} from "../util";
|
||||
|
||||
|
||||
export class UserNoteAction extends Action {
|
||||
content: string;
|
||||
type: UserNoteType;
|
||||
allowDuplicate: boolean;
|
||||
existingNoteCheck?: UserNoteCriteria
|
||||
|
||||
constructor(options: UserNoteActionOptions) {
|
||||
super(options);
|
||||
const {type, content = '', allowDuplicate = false} = options;
|
||||
const {type, content = '', existingNoteCheck = true, allowDuplicate} = options;
|
||||
this.type = type;
|
||||
this.content = content;
|
||||
this.allowDuplicate = allowDuplicate;
|
||||
if(typeof existingNoteCheck !== 'boolean') {
|
||||
this.existingNoteCheck = existingNoteCheck;
|
||||
} else {
|
||||
let exNotecheck: boolean;
|
||||
if(allowDuplicate !== undefined) {
|
||||
exNotecheck = !allowDuplicate;
|
||||
} else {
|
||||
exNotecheck = existingNoteCheck;
|
||||
}
|
||||
this.existingNoteCheck = this.generateCriteriaFromDuplicateConvenience(exNotecheck);
|
||||
}
|
||||
}
|
||||
|
||||
getKind(): ActionTypes {
|
||||
@@ -33,25 +47,30 @@ export class UserNoteAction extends Action {
|
||||
const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string);
|
||||
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
|
||||
|
||||
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`
|
||||
};
|
||||
}
|
||||
let noteCheckPassed: boolean = true;
|
||||
let noteCheckResult: undefined | string;
|
||||
|
||||
if(this.existingNoteCheck === undefined) {
|
||||
// nothing to do!
|
||||
noteCheckResult = 'existingNoteCheck=false so no existing note checks were performed.';
|
||||
} else {
|
||||
const noteCheckCriteriaResult = await this.resources.isAuthor(item, {
|
||||
userNotes: [this.existingNoteCheck]
|
||||
});
|
||||
noteCheckPassed = noteCheckCriteriaResult.passed;
|
||||
const {details} = buildFilterCriteriaSummary(noteCheckCriteriaResult);
|
||||
noteCheckResult = `${noteCheckPassed ? 'Existing note check condition succeeded' : 'Will not add note because existing note check condition failed'} -- ${details.join(' ')}`;
|
||||
}
|
||||
|
||||
this.logger.info(noteCheckResult);
|
||||
if (!noteCheckPassed) {
|
||||
return {
|
||||
dryRun,
|
||||
success: false,
|
||||
result: noteCheckResult
|
||||
};
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await this.resources.userNotes.addUserNote(item, this.type, renderedContent, this.name !== undefined ? `(Action ${this.name})` : '');
|
||||
} else if (!await this.resources.userNotes.warningExists(this.type)) {
|
||||
@@ -64,11 +83,23 @@ export class UserNoteAction extends Action {
|
||||
}
|
||||
}
|
||||
|
||||
generateCriteriaFromDuplicateConvenience(val: boolean): UserNoteCriteria | undefined {
|
||||
if(val) {
|
||||
return {
|
||||
type: this.type,
|
||||
note: this.content !== '' && this.content !== undefined && this.content !== null ? [this.content] : undefined,
|
||||
search: 'current',
|
||||
count: '< 1'
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected getSpecificPremise(): object {
|
||||
return {
|
||||
content: this.content,
|
||||
type: this.type,
|
||||
allowDuplicate: this.allowDuplicate
|
||||
existingNoteCheck: this.existingNoteCheck
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,10 +107,29 @@ export class UserNoteAction extends Action {
|
||||
export interface UserNoteActionConfig extends ActionConfig,UserNoteJson {
|
||||
/**
|
||||
* Add Note even if a Note already exists for this Activity
|
||||
*
|
||||
* USE `existingNoteCheck` INSTEAD
|
||||
*
|
||||
* @examples [false]
|
||||
* @default false
|
||||
* @deprecated
|
||||
* */
|
||||
allowDuplicate?: boolean,
|
||||
|
||||
/**
|
||||
* Check if there is an existing Note matching some criteria before adding the Note.
|
||||
*
|
||||
* If this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.
|
||||
*
|
||||
* Boolean convenience:
|
||||
*
|
||||
* * If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria
|
||||
* * If `false` then no check is performed and Note is always added
|
||||
*
|
||||
* @examples [true]
|
||||
* @default true
|
||||
* */
|
||||
existingNoteCheck?: boolean | UserNoteCriteria,
|
||||
}
|
||||
|
||||
export interface UserNoteActionOptions extends Omit<UserNoteActionConfig, 'authorIs' | 'itemIs'>, ActionOptions {
|
||||
|
||||
224
src/Bot/ResourcesManager.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import {SPoll} from "../Subreddit/Streams";
|
||||
import Snoowrap from "snoowrap";
|
||||
import {Cache} from "cache-manager";
|
||||
import {
|
||||
BotInstanceConfig,
|
||||
StrongCache,
|
||||
StrongTTLConfig,
|
||||
ThirdPartyCredentialsJsonConfig,
|
||||
TTLConfig
|
||||
} from "../Common/interfaces";
|
||||
import winston, {Logger} from "winston";
|
||||
import {DataSource, Repository} from "typeorm";
|
||||
import {
|
||||
EventRetentionPolicyRange
|
||||
} from "../Common/Infrastructure/Atomic";
|
||||
import {InvokeeType} from "../Common/Entities/InvokeeType";
|
||||
import {RunStateType} from "../Common/Entities/RunStateType";
|
||||
import {buildCachePrefix, cacheStats, mergeArr, toStrongTTLConfig} from "../util";
|
||||
import objectHash from "object-hash";
|
||||
import {runMigrations} from "../Common/Migrations/CacheMigrationUtils";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
import {DEFAULT_FOOTER, SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {SubredditResourceConfig, SubredditResourceOptions} from "../Common/Subreddit/SubredditResourceInterfaces";
|
||||
import {buildCacheOptionsFromProvider, CMCache, createCacheManager} from "../Common/Cache";
|
||||
|
||||
export class BotResourcesManager {
|
||||
resources: Map<string, SubredditResources> = new Map();
|
||||
authorTTL: number = 10000;
|
||||
enabled: boolean = true;
|
||||
modStreams: Map<string, SPoll<Snoowrap.Submission | Snoowrap.Comment>> = new Map();
|
||||
defaultCache: CMCache;
|
||||
defaultCacheConfig: StrongCache
|
||||
defaultCacheMigrated: boolean = false;
|
||||
cacheType: string = 'none';
|
||||
cacheHash: string;
|
||||
ttlDefaults: StrongTTLConfig
|
||||
defaultThirdPartyCredentials: ThirdPartyCredentialsJsonConfig;
|
||||
logger: Logger;
|
||||
botAccount?: string;
|
||||
defaultDatabase: DataSource
|
||||
botName!: string
|
||||
retention?: EventRetentionPolicyRange
|
||||
|
||||
invokeeRepo: Repository<InvokeeType>
|
||||
runTypeRepo: Repository<RunStateType>
|
||||
|
||||
constructor(config: BotInstanceConfig, logger: Logger) {
|
||||
const {
|
||||
caching: {
|
||||
authorTTL,
|
||||
userNotesTTL,
|
||||
wikiTTL,
|
||||
commentTTL,
|
||||
submissionTTL,
|
||||
subredditTTL,
|
||||
filterCriteriaTTL,
|
||||
modNotesTTL,
|
||||
selfTTL,
|
||||
provider,
|
||||
},
|
||||
name,
|
||||
credentials: {
|
||||
reddit,
|
||||
...thirdParty
|
||||
},
|
||||
database,
|
||||
databaseConfig: {
|
||||
retention
|
||||
} = {},
|
||||
caching,
|
||||
} = config;
|
||||
caching.provider.prefix = buildCachePrefix([caching.provider.prefix, 'SHARED']);
|
||||
const {...relevantCacheSettings} = caching;
|
||||
this.cacheHash = objectHash.sha1(relevantCacheSettings);
|
||||
this.defaultCacheConfig = caching;
|
||||
this.defaultThirdPartyCredentials = thirdParty;
|
||||
this.defaultDatabase = database;
|
||||
this.ttlDefaults = toStrongTTLConfig({
|
||||
authorTTL,
|
||||
userNotesTTL,
|
||||
wikiTTL,
|
||||
commentTTL,
|
||||
submissionTTL,
|
||||
filterCriteriaTTL,
|
||||
subredditTTL,
|
||||
selfTTL,
|
||||
modNotesTTL
|
||||
});
|
||||
this.botName = name as string;
|
||||
this.logger = logger;
|
||||
this.invokeeRepo = this.defaultDatabase.getRepository(InvokeeType);
|
||||
this.runTypeRepo = this.defaultDatabase.getRepository(RunStateType);
|
||||
this.retention = retention;
|
||||
|
||||
const options = provider;
|
||||
this.cacheType = options.store;
|
||||
|
||||
const cache = createCacheManager(options);
|
||||
this.defaultCache = new CMCache(cache, options, true, caching.provider.prefix, this.ttlDefaults, this.logger);
|
||||
}
|
||||
|
||||
get(subName: string): SubredditResources | undefined {
|
||||
if (this.resources.has(subName)) {
|
||||
return this.resources.get(subName) as SubredditResources;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async set(subName: string, initOptions: SubredditResourceConfig): Promise<SubredditResources> {
|
||||
let hash = 'default';
|
||||
const {caching, credentials, retention, ...init} = initOptions;
|
||||
|
||||
const res = this.get(subName);
|
||||
|
||||
let opts: SubredditResourceOptions = {
|
||||
cache: this.defaultCache,
|
||||
cacheType: this.cacheType,
|
||||
cacheSettingsHash: hash,
|
||||
ttl: this.ttlDefaults,
|
||||
thirdPartyCredentials: credentials ?? this.defaultThirdPartyCredentials,
|
||||
prefix: this.defaultCacheConfig.provider.prefix,
|
||||
database: this.defaultDatabase,
|
||||
botName: this.botName,
|
||||
retention: retention ?? this.retention,
|
||||
...init,
|
||||
};
|
||||
|
||||
if (caching !== undefined) {
|
||||
const {
|
||||
provider = this.defaultCacheConfig.provider,
|
||||
...rest
|
||||
} = caching;
|
||||
|
||||
opts.ttl = toStrongTTLConfig({
|
||||
...this.ttlDefaults,
|
||||
...rest
|
||||
});
|
||||
|
||||
const candidateProvider = buildCacheOptionsFromProvider(provider);
|
||||
|
||||
const defaultPrefix = candidateProvider.prefix;
|
||||
const subPrefix = defaultPrefix === this.defaultCacheConfig.provider.prefix ? buildCachePrefix([(defaultPrefix !== undefined ? defaultPrefix.replace('SHARED', '') : defaultPrefix), subName]) : candidateProvider.prefix;
|
||||
candidateProvider.prefix = subPrefix;
|
||||
|
||||
if(this.defaultCache.equalProvider(candidateProvider)) {
|
||||
opts.cache = this.defaultCache;
|
||||
} else if(res !== undefined && res.cache.equalProvider(candidateProvider)) {
|
||||
opts.cache = res.cache;
|
||||
} else {
|
||||
opts.cache = new CMCache(createCacheManager(candidateProvider), candidateProvider, false, this.defaultCache.providerOptions.prefix, opts.ttl, this.logger);
|
||||
await runMigrations(opts.cache.cache, opts.cache.logger, candidateProvider.prefix);
|
||||
}
|
||||
|
||||
} else if (!this.defaultCacheMigrated) {
|
||||
await runMigrations(this.defaultCache.cache, this.logger, opts.prefix);
|
||||
this.defaultCacheMigrated = true;
|
||||
}
|
||||
|
||||
let resource: SubredditResources;
|
||||
if (res === undefined) {
|
||||
resource = new SubredditResources(subName, {
|
||||
...opts,
|
||||
botAccount: this.botAccount
|
||||
});
|
||||
this.resources.set(subName, resource);
|
||||
} else {
|
||||
// just set non-cache related settings
|
||||
resource = res;
|
||||
resource.botAccount = this.botAccount;
|
||||
}
|
||||
await resource.configure(opts);
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
async destroy(subName: string) {
|
||||
const res = this.get(subName);
|
||||
if (res !== undefined) {
|
||||
await res.destroy();
|
||||
this.resources.delete(subName);
|
||||
}
|
||||
}
|
||||
|
||||
async getPendingSubredditInvites(): Promise<(string[])> {
|
||||
const subredditNames = await this.defaultCache.get(`modInvites`);
|
||||
if (subredditNames !== undefined && subredditNames !== null) {
|
||||
return subredditNames as string[];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async addPendingSubredditInvite(subreddit: string): Promise<void> {
|
||||
if (subreddit === null || subreddit === undefined || subreddit == '') {
|
||||
throw new CMError('Subreddit name cannot be empty');
|
||||
}
|
||||
let subredditNames = await this.defaultCache.get(`modInvites`) as (string[] | undefined | null);
|
||||
if (subredditNames === undefined || subredditNames === null) {
|
||||
subredditNames = [];
|
||||
}
|
||||
const cleanName = subreddit.trim();
|
||||
|
||||
if (subredditNames.some(x => x.trim().toLowerCase() === cleanName.toLowerCase())) {
|
||||
throw new CMError(`An invite for the Subreddit '${subreddit}' already exists`);
|
||||
}
|
||||
subredditNames.push(cleanName);
|
||||
await this.defaultCache.set(`modInvites`, subredditNames, {ttl: 0});
|
||||
return;
|
||||
}
|
||||
|
||||
async deletePendingSubredditInvite(subreddit: string): Promise<void> {
|
||||
let subredditNames = await this.defaultCache.get(`modInvites`) as (string[] | undefined | null);
|
||||
if (subredditNames === undefined || subredditNames === null) {
|
||||
subredditNames = [];
|
||||
}
|
||||
subredditNames = subredditNames.filter(x => x.toLowerCase() !== subreddit.trim().toLowerCase());
|
||||
await this.defaultCache.set(`modInvites`, subredditNames, {ttl: 0});
|
||||
return;
|
||||
}
|
||||
|
||||
async clearPendingSubredditInvites(): Promise<void> {
|
||||
await this.defaultCache.del(`modInvites`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
250
src/Bot/index.ts
@@ -1,4 +1,5 @@
|
||||
import Snoowrap, {Comment, ConfigOptions, RedditUser, Submission, Subreddit} from "snoowrap";
|
||||
import Snoowrap, {Comment, ConfigOptions, RedditUser, Submission} from "snoowrap";
|
||||
import {Subreddit} from "snoowrap/dist/objects"
|
||||
import {Logger} from "winston";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {Duration} from "dayjs/plugin/duration";
|
||||
@@ -24,10 +25,17 @@ import {
|
||||
import {Manager} from "../Subreddit/Manager";
|
||||
import {ExtendedSnoowrap, ProxiedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
import {CommentStream, ModQueueStream, SPoll, SubmissionStream, UnmoderatedStream} from "../Subreddit/Streams";
|
||||
import {BotResourcesManager} from "../Subreddit/SubredditResources";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import pEvent from "p-event";
|
||||
import {SimpleError, isRateLimitError, isRequestError, isScopeError, isStatusError, CMError} from "../Utils/Errors";
|
||||
import {
|
||||
SimpleError,
|
||||
isRateLimitError,
|
||||
isRequestError,
|
||||
isScopeError,
|
||||
isStatusError,
|
||||
CMError,
|
||||
ISeriousError, definesSeriousError
|
||||
} from "../Utils/Errors";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {DataSource, Repository} from "typeorm";
|
||||
import {Bot as BotEntity} from '../Common/Entities/Bot';
|
||||
@@ -43,10 +51,18 @@ import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterSha
|
||||
import {snooLogWrapper} from "../Utils/loggerFactory";
|
||||
import {InfluxClient} from "../Common/Influx/InfluxClient";
|
||||
import {Point} from "@influxdata/influxdb-client";
|
||||
import {BotInstanceFunctions, NormalizedManagerResponse} from "../Web/Common/interfaces";
|
||||
import {
|
||||
BotInstanceFunctions, HydratedSubredditInviteData,
|
||||
NormalizedManagerResponse,
|
||||
SubredditInviteData,
|
||||
SubredditInviteDataPersisted, SubredditOnboardingReadiness
|
||||
} from "../Web/Common/interfaces";
|
||||
import {AuthorEntity} from "../Common/Entities/AuthorEntity";
|
||||
import {Guest, GuestEntityData} from "../Common/Entities/Guest/GuestInterfaces";
|
||||
import {guestEntitiesToAll, guestEntityToApiGuest} from "../Common/Entities/Guest/GuestEntity";
|
||||
import {SubredditInvite} from "../Common/Entities/SubredditInvite";
|
||||
import {dayjsDTFormat} from "../Common/defaults";
|
||||
import {BotResourcesManager} from "./ResourcesManager";
|
||||
|
||||
class Bot implements BotInstanceFunctions {
|
||||
|
||||
@@ -61,6 +77,7 @@ class Bot implements BotInstanceFunctions {
|
||||
excludeSubreddits: string[];
|
||||
filterCriteriaDefaults?: FilterCriteriaDefaults
|
||||
subManagers: Manager[] = [];
|
||||
moderatedSubreddits: Subreddit[] = []
|
||||
heartbeatInterval: number;
|
||||
nextHeartbeat: Dayjs = dayjs();
|
||||
heartBeating: boolean = false;
|
||||
@@ -105,6 +122,8 @@ class Bot implements BotInstanceFunctions {
|
||||
runTypeRepo: Repository<RunStateType>;
|
||||
managerRepo: Repository<ManagerEntity>;
|
||||
authorRepo: Repository<AuthorEntity>;
|
||||
subredditInviteRepo: Repository<SubredditInvite>
|
||||
botRepo: Repository<BotEntity>
|
||||
botEntity!: BotEntity
|
||||
|
||||
getBotName = () => {
|
||||
@@ -168,6 +187,8 @@ class Bot implements BotInstanceFunctions {
|
||||
this.runTypeRepo = this.database.getRepository(RunStateType);
|
||||
this.managerRepo = this.database.getRepository(ManagerEntity);
|
||||
this.authorRepo = this.database.getRepository(AuthorEntity);
|
||||
this.subredditInviteRepo = this.database.getRepository(SubredditInvite)
|
||||
this.botRepo = this.database.getRepository(BotEntity)
|
||||
this.config = config;
|
||||
this.dryRun = parseBool(dryRun) === true ? true : undefined;
|
||||
this.softLimit = softLimit;
|
||||
@@ -406,18 +427,27 @@ class Bot implements BotInstanceFunctions {
|
||||
}
|
||||
}
|
||||
|
||||
async getModeratedSubreddits(refresh = false) {
|
||||
|
||||
if(this.moderatedSubreddits.length > 0 && !refresh) {
|
||||
return this.moderatedSubreddits;
|
||||
}
|
||||
|
||||
let subListing = await this.client.getModeratedSubreddits({count: 100});
|
||||
while (!subListing.isFinished) {
|
||||
subListing = await subListing.fetchMore({amount: 100});
|
||||
}
|
||||
const availSubs = subListing.filter(x => x.display_name !== `u_${this.botUser?.name}`);
|
||||
this.moderatedSubreddits = availSubs;
|
||||
return availSubs;
|
||||
}
|
||||
|
||||
async buildManagers(subreddits: string[] = []) {
|
||||
await this.init();
|
||||
|
||||
this.logger.verbose('Syncing subreddits to moderate with managers...');
|
||||
|
||||
let availSubs: Subreddit[] = [];
|
||||
|
||||
let subListing = await this.client.getModeratedSubreddits({count: 100});
|
||||
while(!subListing.isFinished) {
|
||||
subListing = await subListing.fetchMore({amount: 100});
|
||||
}
|
||||
availSubs = subListing.filter(x => x.display_name !== `u_${this.botUser?.name}`);
|
||||
const availSubs = await this.getModeratedSubreddits(true);
|
||||
|
||||
this.logger.verbose(`${this.botAccount} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
|
||||
|
||||
@@ -499,7 +529,7 @@ class Bot implements BotInstanceFunctions {
|
||||
for (const sub of subsToRun) {
|
||||
if(!this.subManagers.some(x => x.subreddit.display_name === sub.display_name)) {
|
||||
subManagersChanged = true;
|
||||
this.logger.info(`Manager for ${sub.display_name_prefixed} not found in existing managers. Creating now...`);
|
||||
this.logger.info(`Manager for ${sub.display_name_prefixed} not found in loaded managers. Loading now...`);
|
||||
subsToInit.push(sub.display_name);
|
||||
try {
|
||||
this.subManagers.push(await this.createManager(sub));
|
||||
@@ -635,7 +665,7 @@ class Bot implements BotInstanceFunctions {
|
||||
await manager.parseConfiguration('system', true, {suppressNotification: true, suppressChangeEvent: true});
|
||||
} catch (err: any) {
|
||||
if(err.logged !== true) {
|
||||
const normalizedError = new ErrorWithCause(`Bot could not initialize manager because config was not valid`, {cause: err});
|
||||
const normalizedError = new ErrorWithCause(`Bot could not initialize manager`, {cause: err});
|
||||
// @ts-ignore
|
||||
this.logger.error(normalizedError, {subreddit: manager.subreddit.display_name_prefixed});
|
||||
} else {
|
||||
@@ -713,6 +743,9 @@ class Bot implements BotInstanceFunctions {
|
||||
eventsState: new EventsRunState({invokee, runType}),
|
||||
managerState: new ManagerRunState({invokee, runType})
|
||||
}));
|
||||
this.logger.info(`Created new Manager (${managerEntity.id}) for ${subVal.display_name}`);
|
||||
} else {
|
||||
this.logger.info(`Found existing Manager (${managerEntity.id}) for ${subVal.display_name}`);
|
||||
}
|
||||
|
||||
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {
|
||||
@@ -760,21 +793,50 @@ class Bot implements BotInstanceFunctions {
|
||||
}
|
||||
|
||||
async checkModInvites() {
|
||||
const subs: string[] = await this.cacheManager.getPendingSubredditInvites();
|
||||
for (const name of subs) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await this.client.getSubreddit(name).acceptModeratorInvite();
|
||||
this.logger.info(`Accepted moderator invite for r/${name}!`);
|
||||
await this.cacheManager.deletePendingSubredditInvite(name);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes('NO_INVITE_FOUND')) {
|
||||
this.logger.warn(`No pending moderation invite for r/${name} was found`);
|
||||
} else if (isStatusError(err) && err.statusCode === 403) {
|
||||
this.logger.error(`Error occurred while checking r/${name} for a pending moderation invite. It is likely that this bot does not have the 'modself' oauth permission. Error: ${err.message}`);
|
||||
} else {
|
||||
this.logger.error(`Error occurred while checking r/${name} for a pending moderation invite. Error: ${err.message}`);
|
||||
this.logger.debug('Checking onboarding invites...');
|
||||
const expired = this.botEntity.getSubredditInvites().filter(x => x.expiresAt !== undefined && x.expiresAt.isSameOrBefore(dayjs()));
|
||||
for (const exp of expired) {
|
||||
this.logger.debug(`Onboarding invite for ${exp.subreddit} expired at ${exp.expiresAt?.format(dayjsDTFormat)}`);
|
||||
await this.deleteSubredditInvite(exp);
|
||||
}
|
||||
|
||||
for (const subInvite of this.botEntity.getSubredditInvites()) {
|
||||
if (subInvite.canAutomaticallyAccept()) {
|
||||
try {
|
||||
await this.acceptModInvite(subInvite);
|
||||
await this.deleteSubredditInvite(subInvite);
|
||||
} catch (err: any) {
|
||||
if(definesSeriousError(err) && !err.isSerious) {
|
||||
this.logger.warn(err);
|
||||
} else {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.logger.debug(`Cannot try to automatically accept mod invite for ${subInvite.subreddit} because it has additional settings that require moderator approval`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async acceptModInvite(invite: SubredditInvite) {
|
||||
const {subreddit: name} = invite;
|
||||
try {
|
||||
// @ts-ignore
|
||||
await this.client.getSubreddit(name).acceptModeratorInvite();
|
||||
this.logger.info(`Accepted moderator invite for r/${name}!`);
|
||||
} catch (err: any) {
|
||||
if (err.message.includes('NO_INVITE_FOUND')) {
|
||||
throw new SimpleError(`No pending moderation invite for r/${name} was found`, {isSerious: false});
|
||||
} else if (isStatusError(err) && err.statusCode === 403) {
|
||||
let msg = `Error occurred while checking r/${name} for a pending moderation invite.`;
|
||||
if(!this.client.scope.includes('modself')) {
|
||||
msg = `${msg} This bot must have the 'modself' oauth permission in order to accept invites.`;
|
||||
} else {
|
||||
msg = `${msg} If this subreddit is private it is likely no moderation invite exists.`;
|
||||
}
|
||||
throw new CMError(msg, {cause: err})
|
||||
} else {
|
||||
throw new CMError(`Error occurred while checking r/${name} for a pending moderation invite.`, {cause: err});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1236,6 +1298,140 @@ class Bot implements BotInstanceFunctions {
|
||||
|
||||
return newGuests;
|
||||
}
|
||||
|
||||
async addSubredditInvite(data: HydratedSubredditInviteData){
|
||||
let sub: Subreddit;
|
||||
let name: string;
|
||||
if (data.subreddit instanceof Subreddit) {
|
||||
sub = data.subreddit;
|
||||
name = sub.display_name;
|
||||
} else {
|
||||
try {
|
||||
const maybeName = parseRedditEntity(data.subreddit);
|
||||
name = maybeName.name;
|
||||
} catch (e: any) {
|
||||
throw new SimpleError(`Value '${data.subreddit}' is not a valid subreddit name`);
|
||||
}
|
||||
try {
|
||||
const [exists, foundSub] = await this.client.subredditExists(name);
|
||||
if (!exists) {
|
||||
throw new SimpleError(`No subreddit with the name ${name} exists`);
|
||||
}
|
||||
if (foundSub !== undefined) {
|
||||
name = foundSub.display_name;
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
if((await this.subredditInviteRepo.findOneBy({subreddit: name}))) {
|
||||
throw new CMError(`Invite for ${name} already exists`);
|
||||
}
|
||||
const invite = new SubredditInvite({
|
||||
subreddit: name,
|
||||
initialConfig: data.initialConfig,
|
||||
guests: data.guests,
|
||||
bot: this.botEntity
|
||||
})
|
||||
await this.subredditInviteRepo.save(invite);
|
||||
this.botEntity.addSubredditInvite(invite);
|
||||
return invite;
|
||||
}
|
||||
|
||||
getSubredditInvites(): SubredditInviteDataPersisted[] {
|
||||
if(this.botEntity !== undefined) {
|
||||
return this.botEntity.getSubredditInvites().map(x => x.toSubredditInviteData());
|
||||
}
|
||||
this.logger.warn('No bot entity found');
|
||||
return [];
|
||||
}
|
||||
|
||||
getInvite(id: string): SubredditInvite | undefined {
|
||||
if(this.botEntity !== undefined) {
|
||||
return this.botEntity.getSubredditInvites().find(x => x.id === id);
|
||||
}
|
||||
this.logger.warn('No bot entity found');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
getOnboardingReadiness(invite: SubredditInvite): SubredditOnboardingReadiness {
|
||||
const hasManager = this.subManagers.some(x => x.subreddit.display_name.toLowerCase() === invite.subreddit.toLowerCase());
|
||||
const isMod = this.moderatedSubreddits.some(x => x.display_name.toLowerCase() === invite.subreddit.toLowerCase());
|
||||
return {
|
||||
hasManager,
|
||||
isMod
|
||||
};
|
||||
}
|
||||
|
||||
async finishOnboarding(invite: SubredditInvite) {
|
||||
const readiness = this.getOnboardingReadiness(invite);
|
||||
if (readiness.hasManager || readiness.isMod) {
|
||||
this.logger.info(`Bot is already a mod of ${invite.subreddit}. Finishing onboarding early.`);
|
||||
await this.deleteSubredditInvite(invite);
|
||||
}
|
||||
try {
|
||||
await this.acceptModInvite(invite);
|
||||
} catch (e: any) {
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
// rebuild managers to get new subreddit
|
||||
await this.buildManagers();
|
||||
const manager = this.subManagers.find(x => x.subreddit.display_name.toLowerCase() === invite.subreddit.toLowerCase());
|
||||
if (manager === undefined) {
|
||||
throw new CMError('Accepted moderator invitation but could not find manager after rebuilding??');
|
||||
}
|
||||
const {guests = [], initialConfig} = invite;
|
||||
|
||||
// add guests
|
||||
if (guests.length > 0) {
|
||||
await this.addGuest(guests, dayjs().add(1, 'day'), manager.subreddit.display_name);
|
||||
}
|
||||
|
||||
// set initial config
|
||||
if (initialConfig !== undefined) {
|
||||
let data: string;
|
||||
try {
|
||||
const res = await manager.resources.getExternalResource(initialConfig);
|
||||
data = res.val;
|
||||
} catch (e: any) {
|
||||
throw new CMError(`Accepted moderator invitation but error occurred while trying to fetch config from Initial Config value (${initialConfig})`, {cause: e});
|
||||
}
|
||||
try {
|
||||
await manager.writeConfig(data, 'Generated by Initial Config during onboarding')
|
||||
} catch (e: any) {
|
||||
throw new CMError(`Accepted moderator invitation but error occurred while trying to set wiki config value from initial config (${initialConfig})`, {cause: e});
|
||||
}
|
||||
|
||||
// it's ok if this fails because we've already done all the onboarding steps. user can still access the dashboard and all settings have been applied (even if they were invalid IE config)
|
||||
manager.parseConfiguration('system', true).catch((err: any) => {
|
||||
if(err.logged !== true) {
|
||||
this.logger.error(err, {subreddit: manager.displayLabel});
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch(e: any) {
|
||||
throw e;
|
||||
} finally {
|
||||
await this.deleteSubredditInvite(invite);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSubredditInvite(val: string | SubredditInvite) {
|
||||
let invite: SubredditInvite;
|
||||
if(val instanceof SubredditInvite) {
|
||||
invite = val;
|
||||
} else {
|
||||
const maybeInvite = this.botEntity.getSubredditInvites().find(x => x.subreddit === val);
|
||||
if(maybeInvite === undefined) {
|
||||
throw new CMError(`No invite for subreddit ${val} exists for this Bot`);
|
||||
}
|
||||
invite = maybeInvite;
|
||||
}
|
||||
await this.subredditInviteRepo.delete({id: invite.id});
|
||||
this.botEntity.removeSubredditInvite(invite);
|
||||
}
|
||||
}
|
||||
|
||||
export default Bot;
|
||||
|
||||
34
src/Common/ActivitySource.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {ActivitySourceData, ActivitySourceTypes} from "./Infrastructure/Atomic";
|
||||
import {strToActivitySourceData} from "../util";
|
||||
|
||||
export class ActivitySource {
|
||||
type: ActivitySourceTypes
|
||||
identifier?: string
|
||||
|
||||
constructor(data: string | ActivitySourceData) {
|
||||
if (typeof data === 'string') {
|
||||
const {type, identifier} = strToActivitySourceData(data);
|
||||
this.type = type;
|
||||
this.identifier = identifier;
|
||||
} else {
|
||||
this.type = data.type;
|
||||
this.identifier = data.identifier;
|
||||
}
|
||||
}
|
||||
|
||||
matches(desired: ActivitySource): boolean {
|
||||
if(desired.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
// if this source does not have an identifier (we have already matched type) then it is broad enough to match
|
||||
if(this.identifier === undefined) {
|
||||
return true;
|
||||
}
|
||||
// at this point we know this source has an identifier but desired DOES NOT so this source is more restrictive and does not match
|
||||
if(desired.identifier === undefined) {
|
||||
return false;
|
||||
}
|
||||
// otherwise sources match if identifiers are the same
|
||||
return this.identifier.toLowerCase() === desired.identifier.toLowerCase();
|
||||
}
|
||||
}
|
||||
210
src/Common/Cache/index.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import {CacheProvider} from "../Infrastructure/Atomic";
|
||||
import {CacheOptions, StrongTTLConfig} from "../interfaces";
|
||||
import {cacheOptDefaults} from "../defaults";
|
||||
import cacheManager, {Cache, CachingConfig, WrapArgsType} from "cache-manager";
|
||||
import redisStore from "cache-manager-redis-store";
|
||||
import {create as createMemoryStore} from "../../Utils/memoryStore";
|
||||
import winston, {Logger} from "winston";
|
||||
import {mergeArr, parseStringToRegex, redisScanIterator} from "../../util";
|
||||
import globrex from "globrex";
|
||||
import objectHash from "object-hash";
|
||||
|
||||
export const buildCacheOptionsFromProvider = (provider: CacheProvider | any): CacheOptions => {
|
||||
if (typeof provider === 'string') {
|
||||
return {
|
||||
store: provider as CacheProvider,
|
||||
...cacheOptDefaults
|
||||
}
|
||||
}
|
||||
return {
|
||||
store: 'memory',
|
||||
...cacheOptDefaults,
|
||||
...provider,
|
||||
}
|
||||
}
|
||||
export const createCacheManager = (options: CacheOptions): Cache => {
|
||||
const {store, max, ttl = 60, host = 'localhost', port, auth_pass, db, prefix, ...rest} = options;
|
||||
switch (store) {
|
||||
case 'none':
|
||||
return cacheManager.caching({store: 'none', max, ttl});
|
||||
case 'redis':
|
||||
return cacheManager.caching({
|
||||
store: redisStore,
|
||||
host,
|
||||
port,
|
||||
auth_pass,
|
||||
db,
|
||||
ttl,
|
||||
...rest,
|
||||
});
|
||||
case 'memory':
|
||||
default:
|
||||
//return cacheManager.caching({store: 'memory', max, ttl});
|
||||
return cacheManager.caching({store: {create: createMemoryStore}, max, ttl, shouldCloneBeforeSet: false});
|
||||
}
|
||||
}
|
||||
|
||||
export class CMCache {
|
||||
pruneInterval?: any;
|
||||
prefix?: string
|
||||
cache: Cache
|
||||
isDefaultCache: boolean
|
||||
defaultPrefix?: string
|
||||
providerOptions: CacheOptions;
|
||||
logger!: Logger;
|
||||
|
||||
constructor(cache: Cache, providerOptions: CacheOptions, defaultCache: boolean, defaultPrefix: string | undefined, ttls: Partial<StrongTTLConfig>, logger: Logger) {
|
||||
this.cache = cache;
|
||||
this.providerOptions = providerOptions
|
||||
this.isDefaultCache = defaultCache;
|
||||
this.prefix = this.providerOptions.prefix ?? '';
|
||||
this.defaultPrefix = defaultPrefix ?? '';
|
||||
|
||||
this.setLogger(logger);
|
||||
|
||||
this.setPruneInterval(ttls);
|
||||
}
|
||||
|
||||
setLogger(logger: Logger) {
|
||||
this.logger = logger.child({labels: ['Cache']}, mergeArr);
|
||||
}
|
||||
|
||||
equalProvider(candidate: CacheOptions) {
|
||||
return objectHash.sha1(candidate) === objectHash.sha1(this.providerOptions);
|
||||
}
|
||||
|
||||
setPruneInterval(ttls: Partial<StrongTTLConfig>) {
|
||||
if (this.providerOptions.store === 'memory' && !this.isDefaultCache) {
|
||||
if (this.pruneInterval !== undefined) {
|
||||
clearInterval(this.pruneInterval);
|
||||
}
|
||||
const min = Math.min(60, ...Object.values(ttls).filter(x => typeof x === 'number' && x !== 0) as number[]);
|
||||
if (min > 0) {
|
||||
// set default prune interval
|
||||
this.pruneInterval = setInterval(() => {
|
||||
// @ts-ignore
|
||||
this.cache?.store.prune();
|
||||
this.logger.debug('Pruned cache');
|
||||
// prune interval should be twice the smallest TTL
|
||||
}, min * 1000 * 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getCacheKeyCount() {
|
||||
if (this.cache.store.keys !== undefined) {
|
||||
if (this.providerOptions.store === 'redis') {
|
||||
const keys = await this.cache.store.keys(`${this.prefix}*`);
|
||||
return keys.length;
|
||||
}
|
||||
return (await this.cache.store.keys()).length;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
async interactWithCacheByKeyPattern(pattern: string | RegExp, action: 'get' | 'delete') {
|
||||
let patternIsReg = pattern instanceof RegExp;
|
||||
let regPattern: RegExp;
|
||||
let globPattern = pattern;
|
||||
|
||||
const cacheDict: Record<string, any> = {};
|
||||
|
||||
if (typeof pattern === 'string') {
|
||||
const possibleRegPattern = parseStringToRegex(pattern, 'ig');
|
||||
if (possibleRegPattern !== undefined) {
|
||||
regPattern = possibleRegPattern;
|
||||
patternIsReg = true;
|
||||
} else {
|
||||
if (this.prefix !== undefined && !pattern.includes(this.prefix)) {
|
||||
// need to add wildcard to beginning of pattern so that the regex will still match a key with a prefix
|
||||
globPattern = `${this.prefix}${pattern}`;
|
||||
}
|
||||
// @ts-ignore
|
||||
const result = globrex(globPattern, {flags: 'i'});
|
||||
regPattern = result.regex;
|
||||
}
|
||||
} else {
|
||||
regPattern = pattern;
|
||||
}
|
||||
|
||||
if (this.providerOptions.store === 'redis') {
|
||||
// @ts-ignore
|
||||
const redisClient = this.cache.store.getClient();
|
||||
if (patternIsReg) {
|
||||
// scan all and test key by regex
|
||||
for await (const key of redisClient.scanIterator()) {
|
||||
if (regPattern.test(key) && (this.prefix === undefined || key.includes(this.prefix))) {
|
||||
if (action === 'delete') {
|
||||
await redisClient.del(key)
|
||||
} else {
|
||||
cacheDict[key] = await redisClient.get(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// not a regex means we can use glob pattern (more efficient!)
|
||||
for await (const key of redisScanIterator(redisClient, {MATCH: globPattern})) {
|
||||
if (action === 'delete') {
|
||||
await redisClient.del(key)
|
||||
} else {
|
||||
cacheDict[key] = await redisClient.get(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.cache.store.keys !== undefined) {
|
||||
for (const key of await this.cache.store.keys()) {
|
||||
if (regPattern.test(key) && (this.prefix === undefined || key.includes(this.prefix))) {
|
||||
if (action === 'delete') {
|
||||
await this.cache.del(key)
|
||||
} else {
|
||||
cacheDict[key] = await this.cache.get(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cacheDict;
|
||||
}
|
||||
|
||||
async deleteCacheByKeyPattern(pattern: string | RegExp) {
|
||||
return await this.interactWithCacheByKeyPattern(pattern, 'delete');
|
||||
}
|
||||
|
||||
async getCacheByKeyPattern(pattern: string | RegExp) {
|
||||
return await this.interactWithCacheByKeyPattern(pattern, 'get');
|
||||
}
|
||||
|
||||
get store() {
|
||||
return this.cache.store;
|
||||
}
|
||||
|
||||
del(key: string, shared = false): Promise<any> {
|
||||
return this.cache.del(`${shared ? this.defaultPrefix : this.prefix}${key}`);
|
||||
}
|
||||
|
||||
get<T>(key: string, shared = false): Promise<T | undefined> {
|
||||
return this.cache.get(`${shared ? this.defaultPrefix : this.prefix}${key}`);
|
||||
}
|
||||
|
||||
reset(): Promise<void> {
|
||||
return this.cache.reset();
|
||||
}
|
||||
|
||||
set<T>(key: string, value: T, options?: CachingConfig & {shared?: boolean}): Promise<T> {
|
||||
const {shared = false} = options || {};
|
||||
return this.cache.set(`${shared ? this.defaultPrefix : this.prefix}${key}`, value, options);
|
||||
}
|
||||
|
||||
wrap<T>(...args: WrapArgsType<T>[]): Promise<T> {
|
||||
const options: any = args.length >= 3 ? args[2] : {};
|
||||
const {shared = false} = options || {};
|
||||
args[0] = `${shared ? this.defaultPrefix : this.prefix}${args[0]}`;
|
||||
return this.cache.wrap(...args);
|
||||
}
|
||||
|
||||
async destroy() {
|
||||
if (this.pruneInterval !== undefined && this.providerOptions.store === 'memory' && !this.isDefaultCache) {
|
||||
clearInterval(this.pruneInterval);
|
||||
this.cache?.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,4 +65,26 @@ export class Bot extends RandomIdBaseEntity implements HasGuests {
|
||||
this.guests = []
|
||||
return [];
|
||||
}
|
||||
|
||||
getSubredditInvites(): SubredditInvite[] {
|
||||
if(this.subredditInvites === undefined) {
|
||||
return [];
|
||||
}
|
||||
return this.subredditInvites;
|
||||
}
|
||||
|
||||
addSubredditInvite(invite: SubredditInvite) {
|
||||
if(this.subredditInvites === undefined) {
|
||||
this.subredditInvites = [];
|
||||
}
|
||||
this.subredditInvites.push(invite);
|
||||
}
|
||||
|
||||
removeSubredditInvite(invite: SubredditInvite) {
|
||||
if(this.subredditInvites === undefined) {
|
||||
return;
|
||||
}
|
||||
const index = this.subredditInvites.findIndex(x => x.id === invite.id);
|
||||
this.subredditInvites.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import Submission from "snoowrap/dist/objects/Submission";
|
||||
import Comment from "snoowrap/dist/objects/Comment";
|
||||
import {ColumnDurationTransformer} from "./Transformers";
|
||||
import { RedditUser } from "snoowrap/dist/objects";
|
||||
import {ActivitySourceTypes, DurationVal, NonDispatchActivitySource, onExistingFoundBehavior} from "../Infrastructure/Atomic";
|
||||
import {ActivitySourceTypes, DurationVal, NonDispatchActivitySourceValue, onExistingFoundBehavior} from "../Infrastructure/Atomic";
|
||||
|
||||
@Entity({name: 'DispatchedAction'})
|
||||
export class DispatchedEntity extends TimeAwareRandomBaseEntity {
|
||||
@@ -53,7 +53,7 @@ export class DispatchedEntity extends TimeAwareRandomBaseEntity {
|
||||
identifier?: string
|
||||
|
||||
@Column("varchar", {nullable: true, length: 200})
|
||||
cancelIfQueued?: boolean | NonDispatchActivitySource | NonDispatchActivitySource[]
|
||||
cancelIfQueued?: boolean | NonDispatchActivitySourceValue | NonDispatchActivitySourceValue[]
|
||||
|
||||
@Column({nullable: true})
|
||||
onExistingFound?: onExistingFoundBehavior
|
||||
@@ -127,7 +127,7 @@ export class DispatchedEntity extends TimeAwareRandomBaseEntity {
|
||||
} else if (cVal === 'false') {
|
||||
this.cancelIfQueued = false;
|
||||
} else if (cVal.includes('[')) {
|
||||
this.cancelIfQueued = JSON.parse(cVal) as NonDispatchActivitySource[];
|
||||
this.cancelIfQueued = JSON.parse(cVal) as NonDispatchActivitySourceValue[];
|
||||
}
|
||||
}
|
||||
if(this.goto === null) {
|
||||
|
||||
@@ -43,7 +43,7 @@ export class ManagerEntity extends RandomIdBaseEntity implements RunningStateEnt
|
||||
@Column("varchar", {length: 200})
|
||||
name!: string;
|
||||
|
||||
@ManyToOne(type => Bot, sub => sub.managers, {cascade: ['insert'], eager: true})
|
||||
@ManyToOne(type => Bot, sub => sub.managers, {eager: true})
|
||||
bot!: Bot;
|
||||
|
||||
@ManyToOne(type => Subreddit, sub => sub.activities, {cascade: ['insert'], eager: true})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from "typeorm";
|
||||
import {InviteData, SubredditInviteData} from "../../Web/Common/interfaces";
|
||||
import {AfterLoad, Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from "typeorm";
|
||||
import {InviteData, SubredditInviteData, SubredditInviteDataPersisted} from "../../Web/Common/interfaces";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {TimeAwareRandomBaseEntity} from "./Base/TimeAwareRandomBaseEntity";
|
||||
import {AuthorEntity} from "./AuthorEntity";
|
||||
@@ -8,6 +8,7 @@ import {Bot} from "./Bot";
|
||||
@Entity()
|
||||
export class SubredditInvite extends TimeAwareRandomBaseEntity implements SubredditInviteData {
|
||||
|
||||
@PrimaryColumn("varchar", {length: 255})
|
||||
subreddit!: string;
|
||||
|
||||
@Column("simple-json", {nullable: true})
|
||||
@@ -16,6 +17,9 @@ export class SubredditInvite extends TimeAwareRandomBaseEntity implements Subred
|
||||
@Column("text")
|
||||
initialConfig?: string
|
||||
|
||||
@PrimaryColumn("varchar", {length: 200})
|
||||
messageId?: string
|
||||
|
||||
@ManyToOne(type => Bot, bot => bot.subredditInvites, {nullable: false, orphanedRowAction: 'delete'})
|
||||
@JoinColumn({name: 'botId', referencedColumnName: 'id'})
|
||||
bot!: Bot;
|
||||
@@ -38,12 +42,13 @@ export class SubredditInvite extends TimeAwareRandomBaseEntity implements Subred
|
||||
}
|
||||
}
|
||||
|
||||
constructor(data?: SubredditInviteData & { expiresIn?: number }) {
|
||||
constructor(data?: SubredditInviteData & { expiresIn?: number, bot: Bot }) {
|
||||
super();
|
||||
if (data !== undefined) {
|
||||
this.subreddit = data.subreddit;
|
||||
this.initialConfig = data.initialConfig;
|
||||
this.guests = data.guests;
|
||||
this.initialConfig = data.initialConfig === null ? undefined : data.initialConfig;
|
||||
this.guests = data.guests === null || data.guests === undefined ? [] : data.guests;
|
||||
this.bot = data.bot;
|
||||
|
||||
|
||||
if (data.expiresIn !== undefined && data.expiresIn !== 0) {
|
||||
@@ -51,4 +56,44 @@ export class SubredditInvite extends TimeAwareRandomBaseEntity implements Subred
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toSubredditInviteData(): SubredditInviteDataPersisted {
|
||||
return {
|
||||
id: this.id,
|
||||
subreddit: this.subreddit,
|
||||
initialConfig: this.getInitialConfig(),
|
||||
guests: this.getGuests(),
|
||||
expiresAt: this.expiresAt !== undefined ? this.expiresAt.unix() : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
getGuests(): string[] {
|
||||
if(this.guests === null || this.guests === undefined) {
|
||||
return [];
|
||||
}
|
||||
return this.guests;
|
||||
}
|
||||
|
||||
getInitialConfig(): string | undefined {
|
||||
if(this.initialConfig === null) {
|
||||
return undefined;
|
||||
}
|
||||
return this.initialConfig;
|
||||
}
|
||||
|
||||
canAutomaticallyAccept() {
|
||||
return this.getGuests().length === 0 && this.getInitialConfig() === undefined;
|
||||
// TODO setup inbox checking to look for reply to messageId (eventually!)
|
||||
}
|
||||
|
||||
@AfterLoad()
|
||||
fixNullable() {
|
||||
if(this.guests === null) {
|
||||
this.guests = undefined;
|
||||
}
|
||||
if(this.initialConfig === null) {
|
||||
this.initialConfig = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
import {InfluxConfig} from "./interfaces";
|
||||
import {InfluxDB, Point, WriteApi, setLogger} from "@influxdata/influxdb-client";
|
||||
import {InfluxDB, Point, WriteApi, setLogger, DEFAULT_WriteOptions, ClientOptions, DEFAULT_RetryDelayStrategyOptions, Logger as InfluxLogger} from "@influxdata/influxdb-client";
|
||||
import {HealthAPI} from "@influxdata/influxdb-client-apis";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {Logger} from "winston";
|
||||
import {mergeArr} from "../../util";
|
||||
import {CMError} from "../../Utils/Errors";
|
||||
import {Agent} from 'http';
|
||||
import {WriteOptions} from "@influxdata/influxdb-client/dist";
|
||||
|
||||
export interface InfluxClientConfig extends InfluxConfig {
|
||||
client?: InfluxDB
|
||||
ready?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Suppress non-error write failures
|
||||
*
|
||||
* These have not yet hit the max retry. On max retry failure Influx logs as ERROR.
|
||||
* The non-error failures are super noisy in the log so suppress them UNLESS debug is turned on
|
||||
*
|
||||
* https://github.com/influxdata/influxdb-client-js/blob/master/packages/core/src/impl/WriteApiImpl.ts#L221
|
||||
* */
|
||||
const extendLogger = (logger: Logger, suppressWriteWarnings = true): InfluxLogger => {
|
||||
return {
|
||||
...logger,
|
||||
error: (message: string, err?: any) => logger.error(message, err),
|
||||
warn: (message: string, err?: any) => {
|
||||
if(suppressWriteWarnings && !message.includes('Write to InfluxDB failed (attempt')) {
|
||||
logger.warn(message, err);
|
||||
} else if(!suppressWriteWarnings) {
|
||||
logger.warn(message, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InfluxClient {
|
||||
config: InfluxConfig;
|
||||
client: InfluxDB;
|
||||
@@ -34,13 +58,14 @@ export class InfluxClient {
|
||||
|
||||
this.config = rest;
|
||||
this.ready = ready;
|
||||
if(client !== undefined) {
|
||||
if (client !== undefined) {
|
||||
this.client = client;
|
||||
} else {
|
||||
this.client = InfluxClient.createClient(this.config);
|
||||
setLogger(this.logger);
|
||||
this.client = InfluxClient.createClient(this.config);
|
||||
setLogger(extendLogger(this.logger, !(rest.debug ?? false)));
|
||||
}
|
||||
this.write = this.client.getWriteApi(config.credentials.org, config.credentials.bucket, 'ms');
|
||||
|
||||
this.write = this.client.getWriteApi(config.credentials.org, config.credentials.bucket, 'ms', InfluxClient.createWriteOptions(this.config, this.logger));
|
||||
this.tags = tags;
|
||||
this.write.useDefaultTags(tags);
|
||||
this.health = new HealthAPI(this.client);
|
||||
@@ -96,13 +121,62 @@ export class InfluxClient {
|
||||
}
|
||||
|
||||
static createClient(config: InfluxConfig): InfluxDB {
|
||||
return new InfluxDB({
|
||||
url: config.credentials.url,
|
||||
token: config.credentials.token,
|
||||
const {
|
||||
credentials,
|
||||
useKeepAliveAgent = true,
|
||||
} = config;
|
||||
|
||||
const clientOptions: ClientOptions = {
|
||||
url: credentials.url,
|
||||
token: credentials.token,
|
||||
writeOptions: InfluxClient.createWriteOptions(config),
|
||||
}
|
||||
if (useKeepAliveAgent) {
|
||||
// reusing connection
|
||||
// https://github.com/influxdata/influxdb-client-js/issues/393#issuecomment-985272866
|
||||
const agent = new Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 20 * 1000, // 20 seconds keep alive
|
||||
})
|
||||
process.on('exit', () => agent.destroy())
|
||||
clientOptions.transportOptions = {agent};
|
||||
}
|
||||
return new InfluxDB(clientOptions);
|
||||
}
|
||||
|
||||
static createWriteOptions(config: InfluxConfig, logger?: Logger): Partial<WriteOptions> {
|
||||
const {
|
||||
writeOptions: {
|
||||
defaultTags: config.defaultTags
|
||||
defaultTags: userDefinedDefaultTags = {},
|
||||
...restUserWriteOptions
|
||||
} = {
|
||||
batchSize: 500,
|
||||
maxRetries: 5,
|
||||
// 30 seconds
|
||||
flushInterval: 30000
|
||||
},
|
||||
defaultTags: legacyDefaultTags = {},
|
||||
debug = false,
|
||||
} = config;
|
||||
|
||||
const allUserDefinedTags = {...legacyDefaultTags, ...userDefinedDefaultTags};
|
||||
|
||||
const writeOptions: Partial<WriteOptions> = {
|
||||
...DEFAULT_WriteOptions,
|
||||
...restUserWriteOptions,
|
||||
defaultTags: allUserDefinedTags
|
||||
}
|
||||
|
||||
if (debug && logger !== undefined) {
|
||||
writeOptions.writeSuccess = (lines: Array<string>) => {
|
||||
logger.debug(`Flushed ${lines.length} lines to server`);
|
||||
};
|
||||
writeOptions.writeRetrySkipped = (entry: { lines: Array<string>; expires: number }) => {
|
||||
logger.warn(`Skipped flushing ${entry.lines.length} lines due to full buffer`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return writeOptions;
|
||||
}
|
||||
|
||||
childClient(logger: Logger, tags: Record<string, string> = {}) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {InfluxDB, WriteApi} from "@influxdata/influxdb-client/dist";
|
||||
import {InfluxDB, WriteApi, WriteOptions} from "@influxdata/influxdb-client/dist";
|
||||
|
||||
export interface InfluxConfig {
|
||||
credentials: InfluxCredentials
|
||||
defaultTags?: Record<string, string>
|
||||
writeOptions?: WriteOptions
|
||||
useKeepAliveAgent?: boolean
|
||||
debug?: boolean
|
||||
}
|
||||
|
||||
export interface InfluxCredentials {
|
||||
|
||||
@@ -17,6 +17,22 @@ import {ActivityType} from "./Reddit";
|
||||
|
||||
export type DurationComparor = string;
|
||||
|
||||
/**
|
||||
* A relative datetime description
|
||||
*
|
||||
* May be either:
|
||||
*
|
||||
* * day of the week (monday, tuesday, etc...)
|
||||
* * cron expression IE `* * 15 *`
|
||||
*
|
||||
* See https://crontab.guru/ for generating expressions
|
||||
*
|
||||
* https://regexr.com/6u3cc
|
||||
*
|
||||
* @pattern ((?:(?:(?:(?:\d+,)+\d+|(?:\d+(?:\/|-|#)\d+)|\d+L?|\*(?:\/\d+)?|L(?:-\d+)?|\?|[A-Z]{3}(?:-[A-Z]{3})?) ?){5,7})$)|(mon|tues|wed|thurs|fri|sat|sun){1}
|
||||
* */
|
||||
export type RelativeDateTimeMatch = string;
|
||||
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare against
|
||||
*
|
||||
@@ -25,7 +41,7 @@ export type DurationComparor = string;
|
||||
* * EX `> 100` => greater than 100
|
||||
* * EX `<= 75%` => less than or equal to 75%
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*((?:\d+)(?:(?:(?:.|,)\d+)+)?)\s*(%?)(.*)$
|
||||
* */
|
||||
export type CompareValueOrPercent = string;
|
||||
export type StringOperator = '>' | '>=' | '<' | '<=';
|
||||
@@ -152,9 +168,16 @@ export type onExistingFoundBehavior = 'replace' | 'skip' | 'ignore';
|
||||
export type ActionTarget = 'self' | 'parent';
|
||||
export type ArbitraryActionTarget = ActionTarget | string;
|
||||
export type InclusiveActionTarget = ActionTarget | 'any';
|
||||
export type DispatchSource = 'dispatch' | `dispatch:${string}`;
|
||||
export type NonDispatchActivitySource = 'poll' | `poll:${PollOn}` | 'user' | `user:${string}`;
|
||||
export type ActivitySourceTypes = 'poll' | 'dispatch' | 'user'; // TODO
|
||||
export const SOURCE_POLL = 'poll';
|
||||
export type SourcePollStr = 'poll';
|
||||
export const SOURCE_DISPATCH = 'dispatch';
|
||||
export type SourceDispatchStr = 'dispatch';
|
||||
export const SOURCE_USER = 'user';
|
||||
export type SourceUserStr = 'user';
|
||||
|
||||
export type DispatchSourceValue = SourceDispatchStr | `dispatch:${string}`;
|
||||
export type NonDispatchActivitySourceValue = SourcePollStr | `poll:${PollOn}` | SourceUserStr | `user:${string}`;
|
||||
export type ActivitySourceTypes = SourcePollStr | SourceDispatchStr | SourceUserStr; // TODO
|
||||
// https://github.com/YousefED/typescript-json-schema/issues/426
|
||||
// https://github.com/YousefED/typescript-json-schema/issues/425
|
||||
// @pattern ^(((poll|dispatch)(:\w+)?)|user)$
|
||||
@@ -172,7 +195,12 @@ export type ActivitySourceTypes = 'poll' | 'dispatch' | 'user'; // TODO
|
||||
*
|
||||
*
|
||||
* */
|
||||
export type ActivitySource = NonDispatchActivitySource | DispatchSource;
|
||||
export type ActivitySourceValue = NonDispatchActivitySourceValue | DispatchSourceValue;
|
||||
|
||||
export interface ActivitySourceData {
|
||||
type: ActivitySourceTypes
|
||||
identifier?: string
|
||||
}
|
||||
|
||||
export type ConfigFormat = 'json' | 'yaml';
|
||||
export type ActionTypes =
|
||||
@@ -266,7 +294,7 @@ export type UserNoteType =
|
||||
|
||||
export const userNoteTypes = ['gooduser', 'spamwatch', 'spamwarn', 'abusewarn', 'ban', 'permban', 'botban'];
|
||||
|
||||
export type ConfigFragmentValidationFunc = (data: object, fetched: boolean) => boolean;
|
||||
export type ConfigFragmentParseFunc = (data: object, fetched: boolean, subreddit?: string) => object | object[];
|
||||
|
||||
export interface WikiContext {
|
||||
wiki: string
|
||||
|
||||
@@ -35,8 +35,8 @@ export const asGenericComparison = (val: any): val is GenericComparison => {
|
||||
return typeof val === 'object' && 'value' in val;
|
||||
}
|
||||
|
||||
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 GENERIC_VALUE_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>-?(?:\d+)(?:(?:(?:.|,)\d+)+)?)(?<extra>\s+.*)*$/
|
||||
export const GENERIC_VALUE_COMPARISON_URL = 'https://regexr.com/6vama';
|
||||
export const parseGenericValueComparison = (val: string, options?: {
|
||||
requireDuration?: boolean,
|
||||
reg?: RegExp
|
||||
@@ -107,8 +107,8 @@ export const parseGenericValueComparison = (val: string, options?: {
|
||||
durationText,
|
||||
}
|
||||
}
|
||||
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%)?(?<extra>.*)$/
|
||||
const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/60a16';
|
||||
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>(?:\d+)(?:(?:(?:.|,)\d+)+)?)\s*(?<percent>%)?(?<extra>.*)$/
|
||||
const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/6valr';
|
||||
export const parseGenericValueOrPercentComparison = (val: string, options?: {requireDuration: boolean}): GenericComparison => {
|
||||
return parseGenericValueComparison(val, {...(options ?? {}), reg: GENERIC_VALUE_PERCENT_COMPARISON});
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import {
|
||||
DurationComparor,
|
||||
ModeratorNameCriteria,
|
||||
ModeratorNames, ModActionType,
|
||||
ModUserNoteLabel
|
||||
ModUserNoteLabel, RelativeDateTimeMatch
|
||||
} from "../Atomic";
|
||||
import {ActivityType} from "../Reddit";
|
||||
import {ActivityType, MaybeActivityType} from "../Reddit";
|
||||
import {GenericComparison, parseGenericValueComparison} from "../Comparisons";
|
||||
import {parseStringToRegexOrLiteralSearch} from "../../../util";
|
||||
import {parseStringToRegexOrLiteralSearch, toModNoteLabel} from "../../../util";
|
||||
import { Submission, Comment } from "snoowrap";
|
||||
import {RedditUser} from "snoowrap/dist/objects";
|
||||
|
||||
/**
|
||||
* Different attributes a `Subreddit` can be in. Only include a property if you want to check it.
|
||||
@@ -118,17 +120,49 @@ export interface UserNoteCriteria extends UserSubredditHistoryCriteria {
|
||||
* @examples ["spamwarn"]
|
||||
* */
|
||||
type: string;
|
||||
/**
|
||||
* The content of the Note to search For.
|
||||
*
|
||||
* * Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.
|
||||
* * Can also be Regular Expression if wrapped in forward slashes IE '\/test.*\/i'
|
||||
* */
|
||||
note?: string | string[]
|
||||
/*
|
||||
* Does this note link to the currently processing Activity?
|
||||
* */
|
||||
referencesCurrentActivity?: boolean
|
||||
}
|
||||
|
||||
export interface FullUserNoteCriteria extends Omit<UserNoteCriteria, 'note'> {
|
||||
note?: RegExp[]
|
||||
}
|
||||
|
||||
export const toFullUserNoteCriteria = (val: UserNoteCriteria): FullUserNoteCriteria => {
|
||||
const {note} = val;
|
||||
let notesVal = undefined;
|
||||
if (note !== undefined) {
|
||||
const notesArr = Array.isArray(note) ? note : [note];
|
||||
notesVal = notesArr.map(x => parseStringToRegexOrLiteralSearch(x));
|
||||
}
|
||||
return {
|
||||
...val,
|
||||
note: notesVal
|
||||
}
|
||||
}
|
||||
|
||||
export interface ModActionCriteria extends UserSubredditHistoryCriteria {
|
||||
type?: ModActionType | ModActionType[]
|
||||
activityType?: ActivityType | ActivityType[]
|
||||
activityType?: MaybeActivityType | MaybeActivityType[]
|
||||
referencesCurrentActivity?: boolean
|
||||
}
|
||||
|
||||
export interface FullModActionCriteria extends Omit<ModActionCriteria, 'count'> {
|
||||
type?: ModActionType[]
|
||||
count?: GenericComparison
|
||||
activityType?: ActivityType[]
|
||||
/*
|
||||
* Does this action/note link to the currently processing Activity?
|
||||
* */
|
||||
activityType?: MaybeActivityType[]
|
||||
}
|
||||
|
||||
export interface ModNoteCriteria extends ModActionCriteria {
|
||||
@@ -136,8 +170,14 @@ export interface ModNoteCriteria extends ModActionCriteria {
|
||||
note?: string | string[]
|
||||
}
|
||||
|
||||
export interface FullModNoteCriteria extends FullModActionCriteria, Omit<ModNoteCriteria, 'note' | 'count' | 'type' | 'activityType'> {
|
||||
export interface FullModNoteCriteria extends FullModActionCriteria {
|
||||
noteType?: ModUserNoteLabel[]
|
||||
/**
|
||||
* The content of the Note to search For.
|
||||
*
|
||||
* * Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.
|
||||
* * Can also be Regular Expression if wrapped in forward slashes IE '\/test.*\/i'
|
||||
* */
|
||||
note?: RegExp[]
|
||||
}
|
||||
|
||||
@@ -166,7 +206,16 @@ export const toFullModNoteCriteria = (val: ModNoteCriteria): FullModNoteCriteria
|
||||
acc.count = parseGenericValueComparison(rawVal);
|
||||
break;
|
||||
case 'activityType':
|
||||
if(rawVal === false) {
|
||||
acc[k] = rawVal
|
||||
} else {
|
||||
acc[k] = rawVal.toLowerCase();
|
||||
}
|
||||
break;
|
||||
case 'noteType':
|
||||
acc[k] = rawVal.map((x: string) => toModNoteLabel(x));
|
||||
break;
|
||||
case 'referencesCurrentActivity':
|
||||
acc[k] = rawVal;
|
||||
break;
|
||||
case 'note':
|
||||
@@ -193,7 +242,7 @@ export interface FullModLogCriteria extends FullModActionCriteria, Omit<ModLogCr
|
||||
description?: RegExp[]
|
||||
}
|
||||
|
||||
const arrayableModLogProps = ['type','activityType','action','description','details', 'type'];
|
||||
const arrayableModLogProps = ['type','activityType','action','description','details'];
|
||||
|
||||
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);
|
||||
@@ -218,8 +267,17 @@ export const toFullModLogCriteria = (val: ModLogCriteria): FullModLogCriteria =>
|
||||
acc.count = parseGenericValueComparison(rawVal);
|
||||
break;
|
||||
case 'activityType':
|
||||
if(rawVal === false) {
|
||||
acc[k] = rawVal
|
||||
} else {
|
||||
acc[k] = rawVal.toLowerCase();
|
||||
}
|
||||
break;
|
||||
case 'type':
|
||||
acc[k as keyof FullModLogCriteria] = rawVal;
|
||||
acc[k] = rawVal.map((x: string) => x.toUpperCase());
|
||||
break;
|
||||
case 'referencesCurrentActivity':
|
||||
acc[k] = rawVal;
|
||||
break;
|
||||
case 'action':
|
||||
case 'description':
|
||||
@@ -245,10 +303,11 @@ export const authorCriteriaProperties = ['name', 'flairCssClass', 'flairText', '
|
||||
* */
|
||||
export interface AuthorCriteria {
|
||||
/**
|
||||
* A list of reddit usernames (case-insensitive) to match against. Do not include the "u/" prefix
|
||||
* A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the "u/" prefix
|
||||
*
|
||||
*
|
||||
* EX to match against /u/FoxxMD and /u/AnotherUser use ["FoxxMD","AnotherUser"]
|
||||
* @examples ["FoxxMD","AnotherUser"]
|
||||
* @examples ["FoxxMD","AnotherUser", "/.*Foxx.\/*i"]
|
||||
* */
|
||||
name?: string[],
|
||||
/**
|
||||
@@ -256,14 +315,25 @@ export interface AuthorCriteria {
|
||||
*
|
||||
* * If `true` then passes if ANY css is assigned
|
||||
* * If `false` then passes if NO css is assigned
|
||||
* * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
* @examples ["red"]
|
||||
* */
|
||||
flairCssClass?: boolean | string | string[],
|
||||
|
||||
/**
|
||||
* The (user) flair background color (or list of) from the subreddit to match against
|
||||
*
|
||||
* * If `true` then passes if ANY css background color is assigned
|
||||
* * If `false` then passes if NO css background is assigned
|
||||
* * If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
flairBackgroundColor?: boolean | string | string[],
|
||||
/**
|
||||
* A (user) flair text value (or list of) from the subreddit to match against
|
||||
*
|
||||
* * If `true` then passes if ANY text is assigned
|
||||
* * If `false` then passes if NO text is assigned
|
||||
* * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
*
|
||||
* @examples ["Approved"]
|
||||
* */
|
||||
@@ -274,6 +344,7 @@ export interface AuthorCriteria {
|
||||
*
|
||||
* * If `true` then passes if ANY template is assigned
|
||||
* * If `false` then passed if NO template is assigned
|
||||
* * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
*
|
||||
* */
|
||||
flairTemplate?: boolean | string | string[]
|
||||
@@ -440,6 +511,21 @@ export interface ActivityState {
|
||||
* */
|
||||
reports?: string
|
||||
age?: DurationComparor
|
||||
|
||||
/**
|
||||
* A relative datetime description to match the date the Activity was created
|
||||
*
|
||||
* May be either:
|
||||
*
|
||||
* * day of the week (monday, tuesday, etc...)
|
||||
* * cron expression IE `* * 15 *`
|
||||
*
|
||||
* See https://crontab.guru/ for generating expressions
|
||||
*
|
||||
* https://regexr.com/6u3cc
|
||||
*
|
||||
* */
|
||||
createdOn?: RelativeDateTimeMatch | RelativeDateTimeMatch[]
|
||||
/**
|
||||
* Test whether the activity is present in dispatched/delayed activities
|
||||
*
|
||||
@@ -469,6 +555,33 @@ export interface ActivityState {
|
||||
*
|
||||
* */
|
||||
source?: string | string[]
|
||||
|
||||
/**
|
||||
* * If `true` then passes if ANY flair
|
||||
* * If `false` then passes if NO flair
|
||||
* * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
authorFlairText?: boolean | string | string[]
|
||||
/**
|
||||
* * If `true` then passes if ANY flair
|
||||
* * If `false` then passes if NO flair
|
||||
* * If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
authorFlairTemplateId?: boolean | string | string[]
|
||||
|
||||
/**
|
||||
* * If `true` then passes if ANY class
|
||||
* * If `false` then passes if NO class
|
||||
* * If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
authorFlairCssClass?: boolean | string | string[]
|
||||
|
||||
/**
|
||||
* * If `true` then passes if ANY color
|
||||
* * If `false` then passes if NO color
|
||||
* * If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
authorFlairBackgroundColor?: boolean | string | string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -491,13 +604,22 @@ export interface SubmissionState extends ActivityState {
|
||||
/**
|
||||
* * If `true` then passes if flair has ANY text
|
||||
* * If `false` then passes if flair has NO text
|
||||
* * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
link_flair_text?: boolean | string | string[]
|
||||
/**
|
||||
* * If `true` then passes if flair has ANY css
|
||||
* * If `false` then passes if flair has NO css
|
||||
* * If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
link_flair_css_class?: boolean | string | string[]
|
||||
|
||||
/**
|
||||
* * If `true` then passes if ANY color
|
||||
* * If `false` then passes if NO color
|
||||
* * If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes.
|
||||
* */
|
||||
link_flair_background_color?: boolean | string | string[]
|
||||
/**
|
||||
* * If `true` then passes if there is ANY flair template id
|
||||
* * If `false` then passes if there is NO flair template id
|
||||
@@ -521,6 +643,23 @@ export interface SubmissionState extends ActivityState {
|
||||
upvoteRatio?: number | CompareValue
|
||||
}
|
||||
|
||||
export const cmToSnoowrapActivityMap: Record<string, keyof (Submission & Comment)> = {
|
||||
authorFlairText: 'author_flair_text',
|
||||
flairText: 'author_flair_text',
|
||||
authorFlairTemplateId: 'author_flair_template_id',
|
||||
authorFlairCssClass: 'author_flair_css_class',
|
||||
authorFlairBackgroundColor: 'author_flair_background_color',
|
||||
flairTemplate: 'link_flair_template_id',
|
||||
flairCssClass: 'author_flair_css_class',
|
||||
}
|
||||
|
||||
export const cmToSnoowrapAuthorMap: Record<string, keyof (Submission & Comment)> = {
|
||||
flairText: 'author_flair_text',
|
||||
flairCssClass: 'author_flair_css_class',
|
||||
flairTemplate: 'author_flair_template_id',
|
||||
flairBackgroundColor: 'author_flair_background_color',
|
||||
}
|
||||
|
||||
export const cmActivityProperties = ['submissionState', 'score', 'reports', 'removed', 'deleted', 'filtered', 'age', 'title'];
|
||||
|
||||
/**
|
||||
|
||||
@@ -41,6 +41,10 @@ export interface FilterOptionsJson<T> {
|
||||
|
||||
}
|
||||
|
||||
export const asFilterOptionsJson = <T>(val: any): val is FilterOptionsJson<T> => {
|
||||
return val !== null && typeof val === 'object' && (val.include !== undefined || val.exclude !== undefined);
|
||||
}
|
||||
|
||||
export interface FilterOptionsConfig<T> extends FilterOptionsJson<T> {
|
||||
|
||||
/**
|
||||
@@ -66,7 +70,7 @@ export interface FilterOptions<T> extends FilterOptionsConfig<T> {
|
||||
|
||||
export type MinimalOrFullFilter<T> = MaybeAnonymousCriteria<T>[] | FilterOptions<T>
|
||||
export type MinimalOrFullMaybeAnonymousFilter<T> = MaybeAnonymousCriteria<T>[] | FilterOptionsConfig<T>
|
||||
export type MinimalOrFullFilterJson<T> = MaybeAnonymousOrStringCriteria<T>[] | FilterOptionsJson<T>
|
||||
export type MinimalOrFullFilterJson<T> = MaybeAnonymousOrStringCriteria<T> | MaybeAnonymousOrStringCriteria<T>[] | FilterOptionsJson<T>
|
||||
export type StructuredFilter<T> = Omit<T, 'authorIs' | 'itemIs'> & {
|
||||
itemIs?: MinimalOrFullFilter<TypedActivityState>
|
||||
authorIs?: MinimalOrFullFilter<AuthorCriteria>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {Comment, Submission} from "snoowrap/dist/objects";
|
||||
import {Comment, RedditUser, Submission, Subreddit} from "snoowrap/dist/objects";
|
||||
import { ValueOf } from "ts-essentials";
|
||||
import {CMError} from "../../Utils/Errors";
|
||||
|
||||
export type ActivityType = 'submission' | 'comment';
|
||||
export type MaybeActivityType = ActivityType | false;
|
||||
export type FullNameTypes = ActivityType | 'user' | 'subreddit' | 'message';
|
||||
|
||||
export interface RedditThing {
|
||||
@@ -20,8 +23,22 @@ export type AuthorHistorySortTime = 'hour' | 'day' | 'week' | 'month' | 'year' |
|
||||
export type AuthorHistoryType = 'comment' | 'submission' | 'overview';
|
||||
export type SnoowrapActivity = Submission | Comment;
|
||||
|
||||
type valueof<T> = T[keyof T]
|
||||
|
||||
/*
|
||||
* Depending on what caching provider is used the results from cache can either be
|
||||
*
|
||||
* * full-fat SnoowrapActivities (memory provider keeps everything in memory!)
|
||||
* * OR json-serialized objects of the data from those activities (all other cache providers)
|
||||
*
|
||||
* we don't know which they are until we retrieve them.
|
||||
* */
|
||||
export type SnoowrapLike = Record<keyof SnoowrapActivity, valueof<SnoowrapActivity>>;
|
||||
export type RedditUserLike = Record<keyof RedditUser, valueof<RedditUser>>;
|
||||
export type SubredditLike = Record<keyof Subreddit, valueof<Subreddit>>;
|
||||
|
||||
export interface CachedFetchedActivitiesResult {
|
||||
pre: SnoowrapActivity[]
|
||||
pre: SnoowrapActivity[] | SnoowrapLike[]
|
||||
rawCount: number
|
||||
apiCount: number
|
||||
preMaxTrigger?: string | null
|
||||
@@ -29,6 +46,7 @@ export interface CachedFetchedActivitiesResult {
|
||||
|
||||
export interface FetchedActivitiesResult extends CachedFetchedActivitiesResult {
|
||||
post: SnoowrapActivity[]
|
||||
pre: SnoowrapActivity[]
|
||||
}
|
||||
|
||||
export type ReportType = 'mod' | 'user';
|
||||
@@ -103,3 +121,48 @@ export interface SubredditActivityBreakdownByType {
|
||||
submission: SubredditActivityBreakdown[]
|
||||
comment: SubredditActivityBreakdown[]
|
||||
}
|
||||
|
||||
/**
|
||||
* * `comment` -> reply to activity with comment as with bot account
|
||||
* * `commentModTeam` -> reply to activity with comment "as subreddit" (modteam account)
|
||||
* * `modmail` -> send a modmail as the bot account
|
||||
* * `modmailSubreddit` -> send a modmial as the subrddit
|
||||
* */
|
||||
export type CMRemovalMessageType = 'comment' | 'commentModTeam' | 'modmailSubreddit' | 'modmail';
|
||||
export const cmRemovalMessageType = ['comment', 'commentModTeam', 'modmailSubreddit', 'modmail'];
|
||||
|
||||
export const toCmRemovalMessageType = (val: string): CMRemovalMessageType => {
|
||||
switch(val.toLowerCase()){
|
||||
case 'comment':
|
||||
return 'comment';
|
||||
case 'modmail':
|
||||
return 'modmail';
|
||||
case 'commentmodteam':
|
||||
return 'commentModTeam';
|
||||
case 'modmailsubreddit':
|
||||
return 'modmailSubreddit';
|
||||
default:
|
||||
throw new CMError(`Removal Message Type '${val}' was not recognized. Valid types: ${cmRemovalMessageType.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type RedditRemovalMessageType = 'public' | 'private' | 'private_exposed' | 'public_as_subreddit';
|
||||
export const redditRemoveMessageTypes = ['public', 'private', 'private_exposed', 'public_as_subreddit'];
|
||||
|
||||
export const cmToRedditRemovalReason = (cmType: CMRemovalMessageType): RedditRemovalMessageType => {
|
||||
switch (cmType) {
|
||||
case 'comment':
|
||||
return 'public';
|
||||
case 'commentModTeam':
|
||||
return 'public_as_subreddit';
|
||||
case 'modmail':
|
||||
return 'private_exposed';
|
||||
case 'modmailSubreddit':
|
||||
return 'private';
|
||||
}
|
||||
}
|
||||
|
||||
export interface RedditRemovalMessageOptions {
|
||||
title?: string
|
||||
lock?: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {MigrationInterface, QueryRunner, Table, TableColumn} from "typeorm"
|
||||
|
||||
export class subredditInvite1663001719622 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const table = await queryRunner.getTable('SubredditInvite') as Table;
|
||||
|
||||
await queryRunner.addColumns(table, [
|
||||
new TableColumn( {
|
||||
name: 'messageId',
|
||||
type: 'varchar',
|
||||
length: '200',
|
||||
isUnique: true,
|
||||
isNullable: true
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
15
src/Common/Migrations/Database/Server/1663609045418-mhs.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
import {RuleType} from "../../../Entities/RuleType";
|
||||
|
||||
export class mhs1663609045418 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.manager.getRepository(RuleType).save([
|
||||
new RuleType('mhs'),
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
50
src/Common/Subreddit/SubredditResourceInterfaces.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
ActivityDispatch,
|
||||
CacheConfig,
|
||||
Footer,
|
||||
StrongTTLConfig,
|
||||
ThirdPartyCredentialsJsonConfig,
|
||||
TTLConfig
|
||||
} from "../interfaces";
|
||||
import {Cache} from "cache-manager";
|
||||
import {Subreddit} from "snoowrap/dist/objects";
|
||||
import {DataSource} from "typeorm";
|
||||
import {Logger} from "winston";
|
||||
import {ExtendedSnoowrap} from "../../Utils/SnoowrapClients";
|
||||
import {ManagerEntity} from "../Entities/ManagerEntity";
|
||||
import {Bot} from "../Entities/Bot";
|
||||
import {EventRetentionPolicyRange, StatisticFrequencyOption} from "../Infrastructure/Atomic";
|
||||
import {CMCache} from "../Cache";
|
||||
|
||||
export interface SubredditResourceOptions extends Footer {
|
||||
ttl: StrongTTLConfig
|
||||
cache: CMCache
|
||||
cacheType: string;
|
||||
cacheSettingsHash: string
|
||||
subreddit: Subreddit,
|
||||
database: DataSource
|
||||
logger: Logger;
|
||||
client: ExtendedSnoowrap;
|
||||
prefix?: string;
|
||||
thirdPartyCredentials: ThirdPartyCredentialsJsonConfig
|
||||
delayedItems?: ActivityDispatch[]
|
||||
botAccount?: string
|
||||
botName: string
|
||||
managerEntity: ManagerEntity
|
||||
botEntity: Bot
|
||||
statFrequency: StatisticFrequencyOption
|
||||
retention?: EventRetentionPolicyRange
|
||||
footer?: false | string
|
||||
}
|
||||
|
||||
export interface SubredditResourceConfig extends Footer {
|
||||
caching?: CacheConfig,
|
||||
subreddit: Subreddit,
|
||||
logger: Logger;
|
||||
client: ExtendedSnoowrap
|
||||
credentials?: ThirdPartyCredentialsJsonConfig
|
||||
managerEntity: ManagerEntity
|
||||
botEntity: Bot
|
||||
statFrequency: StatisticFrequencyOption
|
||||
retention?: EventRetentionPolicyRange
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import {HistoricalStatsDisplay} from "./interfaces";
|
||||
import path from "path";
|
||||
import {FilterCriteriaDefaults} from "./Infrastructure/Filters/FilterShapes";
|
||||
|
||||
export const dayjsDTFormat = 'YYYY-MM-DD HH:mm:ssZ';
|
||||
export const dayjsTimeFormat = 'HH:mm:ss z';
|
||||
|
||||
export const cacheOptDefaults = {ttl: 60, max: 500, checkPeriod: 600};
|
||||
export const cacheTTLDefaults = {
|
||||
authorTTL: 60,
|
||||
@@ -42,4 +45,4 @@ export const filterCriteriaDefault: FilterCriteriaDefaults = {
|
||||
export const defaultDataDir = path.resolve(__dirname, '../..');
|
||||
export const defaultConfigFilenames = ['config.json', 'config.yaml'];
|
||||
|
||||
export const VERSION = '0.12.1';
|
||||
export const VERSION = '0.13.2';
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DurationVal,
|
||||
EventRetentionPolicyRange,
|
||||
JoinOperands,
|
||||
NonDispatchActivitySource,
|
||||
NonDispatchActivitySourceValue,
|
||||
NotificationEventType,
|
||||
NotificationProvider,
|
||||
onExistingFoundBehavior,
|
||||
@@ -481,6 +481,8 @@ export interface TTLConfig {
|
||||
modNotesTTL?: number | boolean;
|
||||
}
|
||||
|
||||
export type StrongTTLConfig = Record<keyof Required<TTLConfig>, number | false>;
|
||||
|
||||
export interface CacheConfig extends TTLConfig {
|
||||
/**
|
||||
* The cache provider and, optionally, a custom configuration for that provider
|
||||
@@ -490,32 +492,9 @@ export interface CacheConfig extends TTLConfig {
|
||||
* To specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`
|
||||
* */
|
||||
provider?: CacheProvider | CacheOptions
|
||||
|
||||
/**
|
||||
* The **maximum** number of Events that the cache should store triggered result summaries for
|
||||
*
|
||||
* These summaries are viewable through the Web UI.
|
||||
*
|
||||
* The value specified by a subreddit cannot be larger than the value set by the Operator for the global/bot config (if set)
|
||||
*
|
||||
* @default 25
|
||||
* @example [25]
|
||||
* */
|
||||
actionedEventsMax?: number
|
||||
}
|
||||
|
||||
export interface OperatorCacheConfig extends CacheConfig {
|
||||
/**
|
||||
* The **default** number of Events that the cache will store triggered result summaries for
|
||||
*
|
||||
* These summaries are viewable through the Web UI.
|
||||
*
|
||||
* The value specified cannot be larger than `actionedEventsMax` for the global/bot config (if set)
|
||||
*
|
||||
* @default 25
|
||||
* @example [25]
|
||||
* */
|
||||
actionedEventsDefault?: number
|
||||
}
|
||||
|
||||
export interface Footer {
|
||||
@@ -672,6 +651,33 @@ export interface ManagerOptions {
|
||||
*
|
||||
* */
|
||||
retention?: EventRetentionPolicyRange
|
||||
|
||||
/**
|
||||
* Enables config sharing
|
||||
*
|
||||
* * (Default) When `false` sharing is not enabled
|
||||
* * When `true` any bot that can access this bot's config wiki page can use inpm t
|
||||
* * When an object, use `include` or `exclude` to define subreddits that can access this config
|
||||
* */
|
||||
sharing?: boolean | string[] | SharingACLConfig
|
||||
}
|
||||
|
||||
export interface SharingACLConfig {
|
||||
/**
|
||||
* A list of subreddits, or regular expressions for subreddit names, that are allowed to access this config
|
||||
* */
|
||||
include?: string[]
|
||||
/**
|
||||
* A list of subreddits, or regular expressions for subreddit names, that are NOT allowed to access this config
|
||||
*
|
||||
* If `include` is defined this property is ignored
|
||||
* */
|
||||
exclude?: string[]
|
||||
}
|
||||
|
||||
export interface StrongSharingACLConfig {
|
||||
include?: RegExp[]
|
||||
exclude?: RegExp[]
|
||||
}
|
||||
|
||||
export interface ThresholdCriteria {
|
||||
@@ -753,8 +759,6 @@ export type StrongCache = {
|
||||
modNotesTTL: number | boolean,
|
||||
filterCriteriaTTL: number | boolean,
|
||||
provider: CacheOptions
|
||||
actionedEventsMax?: number,
|
||||
actionedEventsDefault: number,
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -814,6 +818,11 @@ export interface CacheOptions {
|
||||
* */
|
||||
max?: number
|
||||
|
||||
/**
|
||||
* A prefix to add to all keys
|
||||
* */
|
||||
prefix?: string
|
||||
|
||||
[key:string]: any
|
||||
}
|
||||
|
||||
@@ -1585,6 +1594,9 @@ export interface ThirdPartyCredentialsJsonConfig {
|
||||
youtube?: {
|
||||
apiKey: string
|
||||
}
|
||||
mhs?: {
|
||||
apiKey: string
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -1964,7 +1976,7 @@ export type RequiredItemCrit = Required<(CommentState & SubmissionState)>;
|
||||
|
||||
export interface ActivityDispatchConfig {
|
||||
identifier?: string
|
||||
cancelIfQueued?: boolean | NonDispatchActivitySource | NonDispatchActivitySource[]
|
||||
cancelIfQueued?: boolean | NonDispatchActivitySourceValue | NonDispatchActivitySourceValue[]
|
||||
goto?: string
|
||||
onExistingFound?: onExistingFoundBehavior
|
||||
tardyTolerant?: boolean | DurationVal
|
||||
|
||||
@@ -19,10 +19,11 @@ import {DispatchActionJson} from "../Action/DispatchAction";
|
||||
import {CancelDispatchActionJson} from "../Action/CancelDispatchAction";
|
||||
import {ContributorActionJson} from "../Action/ContributorAction";
|
||||
import {SentimentRuleJSONConfig} from "../Rule/SentimentRule";
|
||||
import {MHSRuleJSONConfig} from "../Rule/MHSRule";
|
||||
import {ModNoteActionJson} from "../Action/ModNoteAction";
|
||||
import {IncludesData} from "./Infrastructure/Includes";
|
||||
import { SubmissionActionJson } from "../Action/SubmissionAction";
|
||||
|
||||
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig
|
||||
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig | MHSRuleJSONConfig
|
||||
|
||||
export type ActionJson = CommentActionJson | SubmissionActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | ModNoteActionJson | string | IncludesData;
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
overwriteMerge,
|
||||
parseBool, parseExternalUrl, parseUrlContext, parseWikiContext, randomId,
|
||||
readConfigFile,
|
||||
removeUndefinedKeys, resolvePathFromEnvWithRelative
|
||||
removeUndefinedKeys, resolvePathFromEnvWithRelative, toStrongSharingACLConfig
|
||||
} from "./util";
|
||||
|
||||
import Ajv, {Schema} from 'ajv';
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
BotCredentialsJsonConfig,
|
||||
BotCredentialsConfig,
|
||||
OperatorFileConfig,
|
||||
PostBehavior
|
||||
PostBehavior, SharingACLConfig
|
||||
} from "./Common/interfaces";
|
||||
import {isRuleSetJSON, RuleSetConfigData, RuleSetConfigHydratedData, RuleSetConfigObject} from "./Rule/RuleSet";
|
||||
import deepEqual from "fast-deep-equal";
|
||||
@@ -74,10 +74,11 @@ import {ErrorWithCause} from "pony-cause";
|
||||
import {RunConfigHydratedData, RunConfigData, RunConfigObject} from "./Run";
|
||||
import {AuthorRuleConfig} from "./Rule/AuthorRule";
|
||||
import {
|
||||
CacheProvider, ConfigFormat, ConfigFragmentValidationFunc,
|
||||
CacheProvider, ConfigFormat, ConfigFragmentParseFunc,
|
||||
PollOn
|
||||
} from "./Common/Infrastructure/Atomic";
|
||||
import {
|
||||
asFilterOptionsJson,
|
||||
FilterCriteriaDefaults,
|
||||
FilterCriteriaDefaultsJson,
|
||||
MaybeAnonymousOrStringCriteria, MinimalOrFullFilter, MinimalOrFullFilterJson, NamedCriteria
|
||||
@@ -169,7 +170,7 @@ export class ConfigBuilder {
|
||||
return validateJson<SubredditConfigData>(config, appSchema, this.logger);
|
||||
}
|
||||
|
||||
async hydrateConfigFragment<T>(val: IncludesData | string | object, resource: SubredditResources, validateFunc?: ConfigFragmentValidationFunc): Promise<T[]> {
|
||||
async hydrateConfigFragment<T>(val: IncludesData | string | object, resource: SubredditResources, parseFunc?: ConfigFragmentParseFunc, subreddit?: string): Promise<T[]> {
|
||||
let includes: IncludesData | undefined = undefined;
|
||||
if(typeof val === 'string') {
|
||||
const strContextResult = parseUrlContext(val);
|
||||
@@ -197,7 +198,7 @@ export class ConfigBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
const resolvedFragment = await resource.getConfigFragment(includes, validateFunc);
|
||||
const resolvedFragment = await resource.getConfigFragment(includes, parseFunc);
|
||||
if(Array.isArray(resolvedFragment)) {
|
||||
return resolvedFragment
|
||||
}
|
||||
@@ -230,18 +231,36 @@ export class ConfigBuilder {
|
||||
let hydratedRunArr: RunConfigData | RunConfigData[];
|
||||
|
||||
try {
|
||||
hydratedRunArr = await this.hydrateConfigFragment<RunConfigData>(r, resource, <RunConfigData>(data: object, fetched: boolean) => {
|
||||
if (fetched) {
|
||||
if (Array.isArray(data)) {
|
||||
for (const runData of data) {
|
||||
hydratedRunArr = await this.hydrateConfigFragment<RunConfigData>(r, resource, <RunConfigData>(data: any, fetched: boolean, subreddit?: string) => {
|
||||
if(data.runs !== undefined && subreddit !== undefined) {
|
||||
const sharing: boolean | SharingACLConfig = data.sharing ?? false;
|
||||
if(sharing === false) {
|
||||
throw new ConfigParseError(`The resource defined at ${r} does not have sharing enabled.`);
|
||||
} else if(sharing !== true) {
|
||||
const strongAcl = toStrongSharingACLConfig(sharing);
|
||||
if(strongAcl.include !== undefined) {
|
||||
if(!strongAcl.include.some(x => x.test(resource.subreddit.display_name))) {
|
||||
throw new ConfigParseError(`The resource defined at ${r} does not have sharing enabled for this subreddit.`);
|
||||
}
|
||||
} else if(strongAcl.exclude !== undefined) {
|
||||
if(strongAcl.exclude.some(x => x.test(resource.subreddit.display_name))) {
|
||||
throw new ConfigParseError(`The resource defined at ${r} does not have sharing enabled for this subreddit.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const runDataVals = data.runs !== undefined ? data.runs : data;
|
||||
if (!fetched) {
|
||||
if (Array.isArray(runDataVals)) {
|
||||
for (const runData of runDataVals) {
|
||||
validateJson<RunConfigData>(runData, runSchema, this.logger);
|
||||
}
|
||||
} else {
|
||||
validateJson<RunConfigData>(data, runSchema, this.logger);
|
||||
validateJson<RunConfigData>(runDataVals, runSchema, this.logger);
|
||||
}
|
||||
return true;
|
||||
return runDataVals;
|
||||
}
|
||||
return true;
|
||||
return runDataVals;
|
||||
});
|
||||
} catch (e: any) {
|
||||
throw new CMError(`Could not fetch or validate Run #${runIndex}`, {cause: e});
|
||||
@@ -272,9 +291,9 @@ export class ConfigBuilder {
|
||||
} else {
|
||||
validateJson<ActivityCheckConfigHydratedData>(data, checkSchema, this.logger);
|
||||
}
|
||||
return true;
|
||||
return data;
|
||||
}
|
||||
return true;
|
||||
return data;
|
||||
});
|
||||
} catch (e: any) {
|
||||
throw new CMError(`Could not fetch or validate Check Config Fragment #${checkIndex} in Run #${runIndex}`, {cause: e});
|
||||
@@ -363,12 +382,14 @@ export class ConfigBuilder {
|
||||
return validatedHydratedConfig;
|
||||
}
|
||||
|
||||
async parseToStructured(config: SubredditConfigData, resource: SubredditResources, filterCriteriaDefaultsFromBot?: FilterCriteriaDefaults, postCheckBehaviorDefaultsFromBot: PostBehavior = {}): Promise<RunConfigObject[]> {
|
||||
async parseToHydrated(config: SubredditConfigData, resource: SubredditResources) {
|
||||
return await this.hydrateConfig(config, resource);
|
||||
}
|
||||
|
||||
async parseToStructured(hydratedConfig: SubredditConfigHydratedData, filterCriteriaDefaultsFromBot?: FilterCriteriaDefaults, postCheckBehaviorDefaultsFromBot: PostBehavior = {}): Promise<RunConfigObject[]> {
|
||||
let namedRules: Map<string, RuleConfigObject> = new Map();
|
||||
let namedActions: Map<string, ActionConfigObject> = new Map();
|
||||
const {filterCriteriaDefaults, postCheckBehaviorDefaults} = config;
|
||||
|
||||
const hydratedConfig = await this.hydrateConfig(config, resource);
|
||||
const {filterCriteriaDefaults, postCheckBehaviorDefaults} = hydratedConfig;
|
||||
|
||||
const {runs: realRuns = []} = hydratedConfig;
|
||||
|
||||
@@ -418,6 +439,7 @@ export class ConfigBuilder {
|
||||
}
|
||||
structuredRuns.push({
|
||||
...r,
|
||||
filterCriteriaDefaults: configFilterDefaultsFromRun,
|
||||
checks: structuredChecks,
|
||||
authorIs: derivedRunAuthorIs,
|
||||
itemIs: derivedRunItemIs
|
||||
@@ -527,7 +549,7 @@ const parseFilterJson = <T>(addToFilter: FilterJsonFuncArg<T>) => (val: MinimalO
|
||||
for (const v of val) {
|
||||
addToFilter(v);
|
||||
}
|
||||
} else {
|
||||
} else if(asFilterOptionsJson<T>(val)) {
|
||||
const {include = [], exclude = []} = val;
|
||||
for (const v of include) {
|
||||
addToFilter(v);
|
||||
@@ -545,46 +567,6 @@ export const extractNamedFilters = (config: SubredditConfigHydratedData, namedAu
|
||||
const parseAuthorIs = parseFilterJson(addToAuthors);
|
||||
const parseItemIs = parseFilterJson(addToItems);
|
||||
|
||||
// const parseAuthorIs = (val: MinimalOrFullFilterJson<AuthorCriteria> | undefined) => {
|
||||
// if (val === undefined) {
|
||||
// return;
|
||||
// }
|
||||
// if (Array.isArray(val)) {
|
||||
// for (const v of val) {
|
||||
// addToAuthors(v);
|
||||
// }
|
||||
// } else {
|
||||
// const {include = [], exclude = []} = val;
|
||||
// for (const v of include) {
|
||||
// addToAuthors(v);
|
||||
// }
|
||||
// for (const v of exclude) {
|
||||
// addToAuthors(v);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const parseItemIs = (val: MinimalOrFullFilterJson<TypedActivityState> | undefined) => {
|
||||
// if (val === undefined) {
|
||||
// return;
|
||||
// }
|
||||
// if (Array.isArray(val)) {
|
||||
// for (const v of val) {
|
||||
// addToItems(v);
|
||||
// }
|
||||
// } else {
|
||||
// const {include = [], exclude = []} = val;
|
||||
// for (const v of include) {
|
||||
// addToItems(v);
|
||||
// }
|
||||
// for (const v of exclude) {
|
||||
// addToItems(v);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
//const namedRules = new Map();
|
||||
|
||||
const {
|
||||
filterCriteriaDefaults,
|
||||
runs = []
|
||||
@@ -642,7 +624,7 @@ const getNamedOrReturn = <T>(namedFilters: Map<string, NamedCriteria<T>>, filter
|
||||
if(!namedFilters.has(x.toLocaleLowerCase())) {
|
||||
throw new Error(`No named ${filterName} criteria with the name "${x}"`);
|
||||
}
|
||||
return namedFilters.get(x) as NamedCriteria<T>;
|
||||
return namedFilters.get(x.toLocaleLowerCase()) as NamedCriteria<T>;
|
||||
}
|
||||
if(asNamedCriteria(x)) {
|
||||
return x;
|
||||
@@ -660,34 +642,36 @@ export const insertNameFilters = (namedAuthorFilters: Map<string, NamedCriteria<
|
||||
authorIs: undefined,
|
||||
itemIs: undefined,
|
||||
}
|
||||
if(val.authorIs !== undefined) {
|
||||
if (val.authorIs !== undefined) {
|
||||
if (Array.isArray(val.authorIs)) {
|
||||
runnableOpts.authorIs = val.authorIs.map(x => getNamedAuthorOrReturn(x))
|
||||
} else {
|
||||
runnableOpts.authorIs = {};
|
||||
|
||||
const {include, exclude} = val.authorIs;
|
||||
if(include !== undefined) {
|
||||
} else if (asFilterOptionsJson<AuthorCriteria>(val.authorIs)) {
|
||||
const {include, exclude, ...rest} = val.authorIs;
|
||||
runnableOpts.authorIs = {...rest};
|
||||
if (include !== undefined) {
|
||||
runnableOpts.authorIs.include = include.map(x => getNamedAuthorOrReturn(x))
|
||||
}
|
||||
if(exclude !== undefined) {
|
||||
} else if (exclude !== undefined) {
|
||||
runnableOpts.authorIs.exclude = exclude.map(x => getNamedAuthorOrReturn(x))
|
||||
}
|
||||
} else {
|
||||
// assume object is criteria
|
||||
runnableOpts.authorIs = [getNamedAuthorOrReturn(val.authorIs)];
|
||||
}
|
||||
}
|
||||
if(val.itemIs !== undefined) {
|
||||
if (val.itemIs !== undefined) {
|
||||
if (Array.isArray(val.itemIs)) {
|
||||
runnableOpts.itemIs = val.itemIs.map(x => getNamedItemOrReturn(x))
|
||||
} else {
|
||||
runnableOpts.itemIs = {};
|
||||
|
||||
const {include, exclude} = val.itemIs;
|
||||
if(include !== undefined) {
|
||||
} else if (asFilterOptionsJson<TypedActivityState>(val.itemIs)) {
|
||||
const {include, exclude, ...rest} = val.itemIs;
|
||||
runnableOpts.itemIs = {...rest};
|
||||
if (include !== undefined) {
|
||||
runnableOpts.itemIs.include = include.map(x => getNamedItemOrReturn(x))
|
||||
}
|
||||
if(exclude !== undefined) {
|
||||
} else if (exclude !== undefined) {
|
||||
runnableOpts.itemIs.exclude = exclude.map(x => getNamedItemOrReturn(x))
|
||||
}
|
||||
} else {
|
||||
// assume object is criteria
|
||||
runnableOpts.itemIs = [getNamedItemOrReturn(val.itemIs)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1245,8 +1229,6 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
|
||||
let cache: StrongCache;
|
||||
let defaultProvider: CacheOptions;
|
||||
let opActionedEventsMax: number | undefined;
|
||||
let opActionedEventsDefault: number = 25;
|
||||
|
||||
const dataDir = process.env.DATA_DIR ?? defaultDataDir;
|
||||
|
||||
@@ -1258,16 +1240,10 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
cache = {
|
||||
...cacheTTLDefaults,
|
||||
provider: defaultProvider,
|
||||
actionedEventsDefault: opActionedEventsDefault,
|
||||
};
|
||||
|
||||
} else {
|
||||
const {provider, actionedEventsMax, actionedEventsDefault = opActionedEventsDefault, ...restConfig} = opCache;
|
||||
|
||||
if (actionedEventsMax !== undefined && actionedEventsMax !== null) {
|
||||
opActionedEventsMax = actionedEventsMax;
|
||||
opActionedEventsDefault = Math.min(actionedEventsDefault, actionedEventsMax);
|
||||
}
|
||||
const {provider, ...restConfig} = opCache;
|
||||
|
||||
if (typeof provider === 'string') {
|
||||
defaultProvider = {
|
||||
@@ -1285,8 +1261,6 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
cache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
actionedEventsMax: opActionedEventsMax,
|
||||
actionedEventsDefault: opActionedEventsDefault,
|
||||
provider: defaultProvider,
|
||||
}
|
||||
}
|
||||
@@ -1425,8 +1399,6 @@ export const buildBotConfig = (data: BotInstanceJsonConfig, opConfig: OperatorCo
|
||||
const {
|
||||
snoowrap: snoowrapOp,
|
||||
caching: {
|
||||
actionedEventsMax: opActionedEventsMax,
|
||||
actionedEventsDefault: opActionedEventsDefault = 25,
|
||||
provider: defaultProvider,
|
||||
} = {},
|
||||
userAgent,
|
||||
@@ -1481,28 +1453,18 @@ export const buildBotConfig = (data: BotInstanceJsonConfig, opConfig: OperatorCo
|
||||
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
actionedEventsDefault: opActionedEventsDefault,
|
||||
actionedEventsMax: opActionedEventsMax,
|
||||
provider: {...defaultProvider as CacheOptions}
|
||||
};
|
||||
} else {
|
||||
const {
|
||||
provider,
|
||||
actionedEventsMax = opActionedEventsMax,
|
||||
actionedEventsDefault = opActionedEventsDefault,
|
||||
...restConfig
|
||||
} = caching;
|
||||
|
||||
botActionedEventsDefault = actionedEventsDefault;
|
||||
if (actionedEventsMax !== undefined) {
|
||||
botActionedEventsDefault = Math.min(actionedEventsDefault, actionedEventsMax);
|
||||
}
|
||||
|
||||
if (typeof provider === 'string') {
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
actionedEventsDefault: botActionedEventsDefault,
|
||||
provider: {
|
||||
store: provider as CacheProvider,
|
||||
...cacheOptDefaults
|
||||
@@ -1513,8 +1475,6 @@ export const buildBotConfig = (data: BotInstanceJsonConfig, opConfig: OperatorCo
|
||||
botCache = {
|
||||
...cacheTTLDefaults,
|
||||
...restConfig,
|
||||
actionedEventsDefault: botActionedEventsDefault,
|
||||
actionedEventsMax,
|
||||
provider: {
|
||||
store,
|
||||
...cacheOptDefaults,
|
||||
|
||||
@@ -81,6 +81,23 @@ export interface HistoryCriteria {
|
||||
|
||||
window: ActivityWindowConfig
|
||||
|
||||
ratio?: {
|
||||
window: ActivityWindowConfig
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare number of parent criteria activities against number of "ratio" activities
|
||||
*
|
||||
* This comparison is always done as (number of parent criteria activities) / (number of ratio activities)
|
||||
*
|
||||
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
|
||||
*
|
||||
* * EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities
|
||||
* * EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*((?:\d+)(?:(?:(?:.|,)\d+)+)?)\s*(%?)(.*)$
|
||||
* */
|
||||
threshold: CompareValueOrPercent
|
||||
}
|
||||
|
||||
/**
|
||||
* The minimum number of **filtered** activities that must exist from the `window` results for this criteria to run
|
||||
* @default 5
|
||||
@@ -170,7 +187,7 @@ export class HistoryRule extends Rule {
|
||||
|
||||
for (const criteria of this.criteria) {
|
||||
|
||||
const {comment, window, submission, total, minActivityCount = 5} = criteria;
|
||||
const {comment, window, submission, total, ratio, minActivityCount = 5} = criteria;
|
||||
|
||||
const {pre: activities, post: filteredActivities} = await this.resources.getAuthorActivitiesWithFilter(item.author, window);
|
||||
|
||||
@@ -251,6 +268,24 @@ export class HistoryRule extends Rule {
|
||||
}
|
||||
}
|
||||
|
||||
let foundRatio = undefined;
|
||||
let ratioTrigger = undefined;
|
||||
if(ratio !== undefined) {
|
||||
const { window: ratioWindow, threshold: ratioThreshold } = ratio;
|
||||
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(ratioThreshold);
|
||||
const ratioWindowConfig = windowConfigToWindowCriteria(ratioWindow);
|
||||
const {post: ratioActivities} = await this.resources.getAuthorActivitiesWithFilter(item.author, ratioWindowConfig);
|
||||
|
||||
const ratioVal = filteredActivities.length / ratioActivities.length;
|
||||
foundRatio = formatNumber(ratioVal);
|
||||
if(isPercent) {
|
||||
const per = value / 100;
|
||||
ratioTrigger = comparisonTextOp(ratioVal, operator, per);
|
||||
} else {
|
||||
ratioTrigger = comparisonTextOp(ratioVal, operator, value);
|
||||
}
|
||||
}
|
||||
|
||||
const firstActivity = activities[0];
|
||||
const lastActivity = activities[activities.length - 1];
|
||||
|
||||
@@ -263,11 +298,13 @@ export class HistoryRule extends Rule {
|
||||
submissionTotal: fSubmissionTotal,
|
||||
commentTotal: fCommentTotal,
|
||||
opTotal: fOpTotal,
|
||||
foundRatio,
|
||||
filteredTotal: filteredActivities.length,
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true),
|
||||
ratioTrigger,
|
||||
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true) && (ratioTrigger === undefined || ratioTrigger === true),
|
||||
subredditBreakdown: getSubredditBreakdownByActivityType(!asOp ? filteredActivities : filteredActivities.filter(x => asSubmission(x) || x.is_submitter))
|
||||
});
|
||||
}
|
||||
@@ -300,6 +337,12 @@ export class HistoryRule extends Rule {
|
||||
|
||||
this.logger.verbose(`${PASS} ${resultData.result}`);
|
||||
return Promise.resolve([true, this.getResult(true, resultData)]);
|
||||
} else {
|
||||
// log failures for easier debugging
|
||||
for(const res of criteriaResults) {
|
||||
const resultData = this.generateResultDataFromCriteria(res);
|
||||
this.logger.verbose(`${FAIL} ${resultData.result}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve([false, this.getResult(false, {result: failCriteriaResult})]);
|
||||
@@ -312,11 +355,13 @@ export class HistoryRule extends Rule {
|
||||
submissionTotal,
|
||||
commentTotal,
|
||||
filteredTotal,
|
||||
foundRatio,
|
||||
opTotal,
|
||||
criteria: {
|
||||
comment,
|
||||
submission,
|
||||
total,
|
||||
ratio,
|
||||
window,
|
||||
},
|
||||
criteria,
|
||||
@@ -324,6 +369,7 @@ export class HistoryRule extends Rule {
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
ratioTrigger,
|
||||
subredditBreakdown,
|
||||
} = results;
|
||||
|
||||
@@ -332,6 +378,7 @@ export class HistoryRule extends Rule {
|
||||
submissionTotal,
|
||||
commentTotal,
|
||||
filteredTotal,
|
||||
foundRatio,
|
||||
opTotal,
|
||||
commentPercent: formatNumber((commentTotal/activityTotal)*100),
|
||||
submissionPercent: formatNumber((submissionTotal/activityTotal)*100),
|
||||
@@ -343,6 +390,7 @@ export class HistoryRule extends Rule {
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
ratioTrigger,
|
||||
subredditBreakdown
|
||||
};
|
||||
|
||||
@@ -350,6 +398,7 @@ export class HistoryRule extends Rule {
|
||||
let totalSummary;
|
||||
let submissionSummary;
|
||||
let commentSummary;
|
||||
let ratioSummary;
|
||||
if(total !== undefined) {
|
||||
const {operator, value, isPercent, displayText} = parseGenericValueOrPercentComparison(total);
|
||||
const suffix = !isPercent ? 'Items' : `(${formatNumber((filteredTotal/activityTotal)*100)}%) of ${activityTotal} Total`;
|
||||
@@ -374,6 +423,13 @@ export class HistoryRule extends Rule {
|
||||
data.commentSummary = commentSummary;
|
||||
thresholdSummary.push(commentSummary);
|
||||
}
|
||||
if(ratio !== undefined) {
|
||||
const {threshold} = ratio;
|
||||
const {operator, value, isPercent, displayText, extra = ''} = parseGenericValueOrPercentComparison(threshold);
|
||||
ratioSummary = `${includePassFailSymbols ? `${submissionTrigger ? PASS : FAIL} ` : ''}Activity Ratio of (${foundRatio}) ${ratioTrigger ? 'passed' : 'did not pass'} test of ${displayText}`;
|
||||
data.ratioSummary = ratioSummary;
|
||||
thresholdSummary.push(ratioSummary);
|
||||
}
|
||||
|
||||
data.thresholdSummary = thresholdSummary.join(' and ');
|
||||
|
||||
|
||||
420
src/Rule/MHSRule.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import {Rule, RuleJSONConfig, RuleOptions} from "./index";
|
||||
import {Comment} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {
|
||||
asComment, boolToString,
|
||||
formatNumber,
|
||||
triggeredIndicator, windowConfigToWindowCriteria
|
||||
} from "../util";
|
||||
import got, {HTTPError} from 'got';
|
||||
import dayjs from 'dayjs';
|
||||
import {map as mapAsync} from 'async';
|
||||
import {
|
||||
comparisonTextOp,
|
||||
GenericComparison,
|
||||
parseGenericValueOrPercentComparison,
|
||||
} from "../Common/Infrastructure/Comparisons";
|
||||
import {ActivityWindowConfig, ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {RuleResult} from "../Common/interfaces";
|
||||
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
import objectHash from "object-hash";
|
||||
|
||||
const formatConfidence = (val: number) => formatNumber(val * 100, {
|
||||
suffix: '%',
|
||||
toFixed: 2
|
||||
});
|
||||
|
||||
export class MHSRule extends Rule {
|
||||
|
||||
criteria: MHSCriteria
|
||||
|
||||
historical?: HistoricalMHS;
|
||||
|
||||
ogConfig: MHSConfig
|
||||
|
||||
constructor(options: MHSRuleOptions) {
|
||||
super(options);
|
||||
|
||||
if (this.resources.thirdPartyCredentials.mhs?.apiKey === undefined) {
|
||||
throw new CMError(`MHS (moderatehatespeech.com) API Key has not been specified. It must be present in the bot config or top-level subreddit 'credentials' property.`);
|
||||
}
|
||||
|
||||
const {
|
||||
criteria,
|
||||
historical,
|
||||
} = options;
|
||||
|
||||
this.ogConfig = {
|
||||
criteria,
|
||||
historical
|
||||
};
|
||||
|
||||
const {
|
||||
flagged = true,
|
||||
confidence = '>= 90',
|
||||
testOn = ['body']
|
||||
} = criteria || {};
|
||||
|
||||
this.criteria = {
|
||||
flagged,
|
||||
confidence: confidence !== undefined ? parseGenericValueOrPercentComparison(confidence) : undefined,
|
||||
testOn,
|
||||
}
|
||||
|
||||
if (options.historical !== undefined) {
|
||||
const {
|
||||
window,
|
||||
criteria: historyCriteria,
|
||||
mustMatchCurrent = false,
|
||||
totalMatching = '> 0',
|
||||
} = options.historical
|
||||
|
||||
let usedCriteria: MHSCriteria;
|
||||
if (historyCriteria === undefined) {
|
||||
usedCriteria = this.criteria;
|
||||
} else {
|
||||
const {
|
||||
flagged: historyFlagged = true,
|
||||
confidence: historyConfidence = '>= 90',
|
||||
testOn: historyTestOn = ['body']
|
||||
} = historyCriteria || {};
|
||||
usedCriteria = {
|
||||
flagged: historyFlagged,
|
||||
confidence: historyConfidence !== undefined ? parseGenericValueOrPercentComparison(historyConfidence) : undefined,
|
||||
testOn: historyTestOn,
|
||||
}
|
||||
}
|
||||
|
||||
this.historical = {
|
||||
criteria: usedCriteria,
|
||||
window: windowConfigToWindowCriteria(window),
|
||||
mustMatchCurrent,
|
||||
totalMatching: parseGenericValueOrPercentComparison(totalMatching),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
getKind(): string {
|
||||
return 'mhs';
|
||||
}
|
||||
|
||||
getSpecificPremise(): object {
|
||||
return this.ogConfig;
|
||||
}
|
||||
|
||||
protected async process(item: Submission | Comment): Promise<[boolean, RuleResult]> {
|
||||
|
||||
let ogResult = await this.testActivity(item, this.criteria);
|
||||
let historicResults: MHSCriteriaResult[] | undefined;
|
||||
let historicalCriteriaTest: string | undefined;
|
||||
|
||||
if (this.historical !== undefined && (!this.historical.mustMatchCurrent || ogResult.passed)) {
|
||||
const {
|
||||
criteria,
|
||||
window,
|
||||
} = this.historical;
|
||||
const history = await this.resources.getAuthorActivities(item.author, window);
|
||||
|
||||
historicResults = await mapAsync(history, async (x: SnoowrapActivity) => await this.testActivity(x, criteria)); // history.map(x => this.testActivity(x, sentiment));
|
||||
}
|
||||
|
||||
const logSummary: string[] = [];
|
||||
|
||||
let triggered = false;
|
||||
let humanWindow: string | undefined;
|
||||
let historicalPassed: string | undefined;
|
||||
let totalMatchingText: string | undefined;
|
||||
|
||||
if (historicResults === undefined) {
|
||||
triggered = ogResult.passed;
|
||||
logSummary.push(`Current Activity MHS Test: ${ogResult.summary}`);
|
||||
if (!triggered && this.historical !== undefined && this.historical.mustMatchCurrent) {
|
||||
logSummary.push(`Did not check Historical because 'mustMatchCurrent' is true`);
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
totalMatching,
|
||||
criteria,
|
||||
} = this.historical as HistoricalMHS;
|
||||
|
||||
historicalCriteriaTest = mhsCriteriaTestDisplay(criteria);
|
||||
|
||||
totalMatchingText = totalMatching.displayText;
|
||||
const allResults = historicResults
|
||||
const passed = allResults.filter(x => x.passed);
|
||||
|
||||
const firstActivity = allResults[0].activity;
|
||||
const lastActivity = allResults[allResults.length - 1].activity;
|
||||
|
||||
const humanRange = dayjs.duration(dayjs(firstActivity.created_utc * 1000).diff(dayjs(lastActivity.created_utc * 1000))).humanize();
|
||||
|
||||
humanWindow = `${allResults.length} Activities (${humanRange})`;
|
||||
|
||||
const {operator, value, isPercent} = totalMatching;
|
||||
if (isPercent) {
|
||||
const passPercentVal = passed.length / allResults.length
|
||||
triggered = comparisonTextOp(passPercentVal, operator, (value / 100));
|
||||
historicalPassed = `${passed.length} (${formatNumber(passPercentVal)}%)`;
|
||||
} else {
|
||||
triggered = comparisonTextOp(passed.length, operator, value);
|
||||
historicalPassed = `${passed.length}`;
|
||||
}
|
||||
logSummary.push(`${triggeredIndicator(triggered)} ${historicalPassed} historical activities of ${humanWindow} passed MHS criteria '${historicalCriteriaTest}' which ${triggered ? 'MET' : 'DID NOT MEET'} threshold '${totalMatching.displayText}'`);
|
||||
}
|
||||
|
||||
const result = logSummary.join(' || ');
|
||||
this.logger.verbose(result);
|
||||
|
||||
return Promise.resolve([triggered, this.getResult(triggered, {
|
||||
result,
|
||||
data: {
|
||||
results: {
|
||||
triggered,
|
||||
criteriaTest: mhsCriteriaTestDisplay(this.criteria),
|
||||
historicalCriteriaTest,
|
||||
window: humanWindow,
|
||||
totalMatching: totalMatchingText
|
||||
}
|
||||
}
|
||||
})]);
|
||||
}
|
||||
|
||||
protected async testActivity(a: SnoowrapActivity, criteria: MHSCriteria): Promise<MHSCriteriaResult> {
|
||||
const content = [];
|
||||
if (asComment(a)) {
|
||||
content.push(a.body);
|
||||
} else {
|
||||
if (criteria.testOn.includes('title')) {
|
||||
content.push(a.title);
|
||||
}
|
||||
if (criteria.testOn.includes('body') && a.is_self) {
|
||||
content.push(a.selftext);
|
||||
}
|
||||
}
|
||||
const mhsResult = await this.getMHSResponse(content.join(' '));
|
||||
|
||||
const {
|
||||
flagged,
|
||||
confidence
|
||||
} = criteria;
|
||||
|
||||
let flaggedPassed: boolean | undefined;
|
||||
let confPassed: boolean | undefined;
|
||||
|
||||
let summary = [];
|
||||
|
||||
if (confidence !== undefined) {
|
||||
const {operator, value} = confidence;
|
||||
confPassed = comparisonTextOp(mhsResult.confidence * 100, operator, value);
|
||||
summary.push(`Confidence test (${confidence.displayText}) ${confPassed ? 'PASSED' : 'DID NOT PASS'} MHS confidence of ${formatConfidence(mhsResult.confidence)}`)
|
||||
}
|
||||
|
||||
if (flagged !== undefined) {
|
||||
flaggedPassed = flagged ? mhsResult.class === 'flag' : mhsResult.class === 'normal';
|
||||
summary.push(`Flagged pass condition of ${flagged} (${flagged ? 'toxic' : 'normal'}) ${flaggedPassed ? 'MATCHED' : 'DID NOT MATCH'} MHS flag '${mhsResult.class === 'flag' ? 'toxic' : 'normal'}' ${confidence === undefined ? ` (${formatConfidence(mhsResult.confidence)} confidence)` : ''}`);
|
||||
}
|
||||
|
||||
const passed = (flaggedPassed === undefined || flaggedPassed) && (confPassed === undefined || confPassed);
|
||||
|
||||
return {
|
||||
activity: a,
|
||||
criteria,
|
||||
mhsResult,
|
||||
passed,
|
||||
summary: `${triggeredIndicator(passed)} ${summary.join(' | ')}`
|
||||
}
|
||||
}
|
||||
|
||||
protected async getMHSResponse(content: string): Promise<MHSResponse> {
|
||||
const hash = objectHash.sha1({content});
|
||||
const key = `mhs-${hash}`;
|
||||
if (this.resources.ttl.wikiTTL !== false) {
|
||||
let res = await this.resources.cache.get(key) as undefined | null | MHSResponse;
|
||||
if(res !== undefined && res !== null) {
|
||||
// don't cache bad responses
|
||||
if(res.response.toLowerCase() === 'success')
|
||||
{
|
||||
return res;
|
||||
}
|
||||
}
|
||||
res = await this.callMHS(content);
|
||||
if(res.response.toLowerCase() === 'success') {
|
||||
await this.resources.cache.set(key, res, {ttl: this.resources.ttl.wikiTTL});
|
||||
}
|
||||
return res;
|
||||
}
|
||||
return this.callMHS(content);
|
||||
}
|
||||
|
||||
protected async callMHS(content: string): Promise<MHSResponse> {
|
||||
try {
|
||||
return await got.post(`https://api.moderatehatespeech.com/api/v1/moderate/`, {
|
||||
headers: {
|
||||
'Content-Type': `application/json`,
|
||||
},
|
||||
json: {
|
||||
token: this.resources.thirdPartyCredentials.mhs?.apiKey,
|
||||
text: content
|
||||
},
|
||||
}).json() as MHSResponse;
|
||||
} catch (err: any) {
|
||||
let error: string | undefined = undefined;
|
||||
if (err instanceof HTTPError) {
|
||||
error = err.response.statusMessage;
|
||||
if (typeof err.response.body === 'string') {
|
||||
error = `(${err.response.statusCode}) ${err.response.body}`;
|
||||
}
|
||||
}
|
||||
throw new CMError(`MHS request failed${error !== undefined ? ` with error: ${error}` : ''}`, {cause: err});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const mhsCriteriaTestDisplay = (criteria: MHSCriteria) => {
|
||||
const summary = [];
|
||||
if (criteria.flagged !== undefined) {
|
||||
summary.push(`${criteria.flagged ? 'IS FLAGGED' : 'IS NOT FLAGGED'} as toxic`);
|
||||
}
|
||||
if (criteria.confidence !== undefined) {
|
||||
summary.push(`MHS confidence is ${criteria.confidence.displayText}`);
|
||||
}
|
||||
return summary.join(' AND ');
|
||||
}
|
||||
|
||||
interface MHSResponse {
|
||||
confidence: number
|
||||
response: string
|
||||
class: 'flag' | 'normal'
|
||||
}
|
||||
|
||||
interface MHSCriteriaResult {
|
||||
mhsResult: MHSResponse
|
||||
criteria: MHSCriteria
|
||||
passed: boolean
|
||||
summary: string,
|
||||
activity: SnoowrapActivity
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the content of Activities from the Author history against MHS criteria
|
||||
*
|
||||
* If this is defined then the `totalMatching` threshold must pass for the Rule to trigger
|
||||
*
|
||||
* If `criteria` is defined here it overrides the top-level `criteria` value
|
||||
*
|
||||
* */
|
||||
interface HistoricalMHSConfig {
|
||||
window: ActivityWindowConfig
|
||||
|
||||
criteria?: MHSCriteriaConfig
|
||||
|
||||
/**
|
||||
* When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
mustMatchCurrent?: boolean
|
||||
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test
|
||||
*
|
||||
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
|
||||
*
|
||||
* * EX `> 12` => greater than 12 activities passed given `criteria` test
|
||||
* * EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
|
||||
* @default "> 0"
|
||||
* @examples ["> 0","> 10%"]
|
||||
* */
|
||||
totalMatching: string
|
||||
}
|
||||
|
||||
interface HistoricalMHS extends Omit<HistoricalMHSConfig, | 'window' | 'totalMatching' | 'criteria'> {
|
||||
window: ActivityWindowCriteria
|
||||
criteria: MHSCriteria
|
||||
totalMatching: GenericComparison
|
||||
}
|
||||
|
||||
/**
|
||||
* Criteria used to trigger based on MHS results
|
||||
*
|
||||
* If both `flagged` and `confidence` are specified then both conditions must pass.
|
||||
*
|
||||
* By default, only `flagged` is defined as `true`
|
||||
* */
|
||||
interface MHSCriteriaConfig {
|
||||
/**
|
||||
* Test if MHS considers content flagged as toxic or not
|
||||
*
|
||||
* @default true
|
||||
* */
|
||||
flagged?: boolean
|
||||
|
||||
/**
|
||||
* A string containing a comparison operator and a value to compare against the confidence returned from MHS
|
||||
*
|
||||
* The syntax is `(< OR > OR <= OR >=) <number>`
|
||||
*
|
||||
* * EX `> 50` => MHS confidence is greater than 50%
|
||||
*
|
||||
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
|
||||
* @examples ["> 50"]
|
||||
* */
|
||||
confidence?: string
|
||||
/**
|
||||
* Which content from an Activity to send to MHS
|
||||
*
|
||||
* Only used if the Activity being tested is a Submission -- Comments can be only tested against their body
|
||||
*
|
||||
* If more than one type of content is specified then all text is tested together as one string
|
||||
*
|
||||
* @default ["body"]
|
||||
* */
|
||||
testOn?: ('title' | 'body')[]
|
||||
}
|
||||
|
||||
interface MHSCriteria extends Omit<MHSCriteriaConfig, 'confidence'> {
|
||||
confidence?: GenericComparison
|
||||
testOn: ('title' | 'body')[]
|
||||
}
|
||||
|
||||
interface MHSConfig {
|
||||
|
||||
criteria?: MHSCriteriaConfig
|
||||
|
||||
/**
|
||||
* run MHS on Activities from the Author history
|
||||
*
|
||||
* If this is defined then the `totalMatching` threshold must pass for the Rule to trigger
|
||||
*
|
||||
* If `criteria` is defined here it overrides the top-level `criteria` value
|
||||
*
|
||||
* */
|
||||
historical?: HistoricalMHSConfig
|
||||
}
|
||||
|
||||
export interface MHSRuleOptions extends MHSConfig, RuleOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test content of an Activity against the MHS toxicity model for reddit content
|
||||
*
|
||||
* Running this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.
|
||||
*
|
||||
* More info:
|
||||
*
|
||||
* * https://moderatehatespeech.com/docs/
|
||||
* * https://moderatehatespeech.com/
|
||||
*
|
||||
* */
|
||||
export interface MHSRuleJSONConfig extends MHSConfig, RuleJSONConfig {
|
||||
/**
|
||||
* @examples ["mhs"]
|
||||
* @default mhs
|
||||
* */
|
||||
kind: 'mhs'
|
||||
}
|
||||
|
||||
export default MHSRule;
|
||||
@@ -44,6 +44,7 @@ import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/Act
|
||||
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {ImageHashCacheData} from "../Common/Infrastructure/Atomic";
|
||||
import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
|
||||
const parseLink = parseUsableLinkIdentifier();
|
||||
|
||||
@@ -188,8 +189,10 @@ export class RecentActivityRule extends Rule {
|
||||
if (inferredSubmissionAsRef) {
|
||||
if (!asSubmission(item)) {
|
||||
this.logger.warn('Cannot use post as reference because triggered item is not a Submission');
|
||||
viableActivity = [];
|
||||
} else if (item.is_self) {
|
||||
this.logger.warn('Cannot use post as reference because triggered Submission is not a link type');
|
||||
viableActivity = [];
|
||||
} else {
|
||||
const itemId = item.id;
|
||||
const referenceUrl = await item.url;
|
||||
@@ -313,7 +316,7 @@ export class RecentActivityRule extends Rule {
|
||||
}
|
||||
} catch (err: any) {
|
||||
if(!err.message.includes('did not end with a valid image extension')) {
|
||||
this.logger.warn(`Will not compare image from Submission ${x.id} due to error while parsing image URL => ${err.message}`);
|
||||
this.logger.warn(new CMError(`Will not compare image from Submission ${x.id} due to error while parsing image URL`, {cause: err}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {RepostRule, RepostRuleJSONConfig} from "./RepostRule";
|
||||
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {SentimentRule, SentimentRuleJSONConfig} from "./SentimentRule";
|
||||
import {StructuredRuleConfigObject} from "../Common/Infrastructure/RuleShapes";
|
||||
import {MHSRuleJSONConfig, MHSRule} from "./MHSRule";
|
||||
|
||||
export function ruleFactory
|
||||
(config: StructuredRuleConfigObject, logger: Logger, subredditName: string, resources: SubredditResources, client: Snoowrap): Rule {
|
||||
@@ -42,6 +43,9 @@ export function ruleFactory
|
||||
case 'sentiment':
|
||||
cfg = config as StructuredFilter<SentimentRuleJSONConfig>;
|
||||
return new SentimentRule({...cfg, logger, subredditName, resources, client});
|
||||
case 'mhs':
|
||||
cfg = config as StructuredFilter<MHSRuleJSONConfig>;
|
||||
return new MHSRule({...cfg, logger, subredditName, resources, client});
|
||||
default:
|
||||
throw new Error(`Rule with kind '${config.kind}' was not recognized.`);
|
||||
}
|
||||
|
||||
@@ -185,5 +185,5 @@ export interface RuleJSONConfig extends IRule {
|
||||
* The kind of rule to run
|
||||
* @examples ["recentActivity", "repeatActivity", "author", "attribution", "history"]
|
||||
*/
|
||||
kind: 'recentActivity' | 'repeatActivity' | 'author' | 'attribution' | 'history' | 'regex' | 'repost' | 'sentiment'
|
||||
kind: 'recentActivity' | 'repeatActivity' | 'author' | 'attribution' | 'history' | 'regex' | 'repost' | 'sentiment' | 'mhs'
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import {RunResultEntity} from "../Common/Entities/RunResultEntity";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {RunnableBase} from "../Common/RunnableBase";
|
||||
import {RunnableBaseJson, RunnableBaseOptions, StructuredRunnableBase} from "../Common/Infrastructure/Runnable";
|
||||
import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {FilterCriteriaDefaults, FilterCriteriaDefaultsJson} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {IncludesData} from "../Common/Infrastructure/Includes";
|
||||
|
||||
export class Run extends RunnableBase {
|
||||
@@ -284,7 +284,7 @@ export interface IRun extends PostBehavior, RunnableBaseJson {
|
||||
*
|
||||
* Default behavior is to exclude all mods and automoderator from checks
|
||||
* */
|
||||
filterCriteriaDefaults?: FilterCriteriaDefaults
|
||||
filterCriteriaDefaults?: FilterCriteriaDefaultsJson
|
||||
|
||||
/**
|
||||
* Use this option to override the `dryRun` setting for all Actions of all Checks in this Run
|
||||
@@ -326,4 +326,5 @@ export interface RunConfigHydratedData extends IRun {
|
||||
|
||||
export interface RunConfigObject extends Omit<RunConfigHydratedData, 'authorIs' | 'itemIs'>, StructuredRunnableBase {
|
||||
checks: ActivityCheckObject[]
|
||||
filterCriteriaDefaults?: FilterCriteriaDefaults
|
||||
}
|
||||
|
||||
@@ -205,6 +205,23 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The (user) flair background color (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css background color is assigned\n* If `false` then passes if NO css background is assigned\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -220,7 +237,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"red"
|
||||
]
|
||||
@@ -240,7 +257,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairText": {
|
||||
"anyOf": [
|
||||
@@ -257,7 +274,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"Approved"
|
||||
]
|
||||
@@ -289,10 +306,11 @@
|
||||
"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\"]",
|
||||
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
"AnotherUser",
|
||||
"/.*Foxx./*i"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -763,6 +781,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1702,18 +1802,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1752,6 +1852,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -1798,14 +1901,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -1856,6 +1951,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1928,18 +2038,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1998,6 +2108,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -2507,6 +2620,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2557,6 +2752,23 @@
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link_flair_background_color": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_css_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2572,7 +2784,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_text": {
|
||||
"anyOf": [
|
||||
@@ -2589,7 +2801,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
@@ -2777,7 +2989,7 @@
|
||||
"properties": {
|
||||
"allowDuplicate": {
|
||||
"default": false,
|
||||
"description": "Add Note even if a Note already exists for this Activity",
|
||||
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
@@ -2833,6 +3045,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -2900,6 +3127,23 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -28,6 +28,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -581,6 +663,23 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The (user) flair background color (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css background color is assigned\n* If `false` then passes if NO css background is assigned\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -596,7 +695,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"red"
|
||||
]
|
||||
@@ -616,7 +715,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairText": {
|
||||
"anyOf": [
|
||||
@@ -633,7 +732,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"Approved"
|
||||
]
|
||||
@@ -665,10 +764,11 @@
|
||||
"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\"]",
|
||||
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
"AnotherUser",
|
||||
"/.*Foxx./*i"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -940,11 +1040,6 @@
|
||||
},
|
||||
"CacheConfig": {
|
||||
"properties": {
|
||||
"actionedEventsMax": {
|
||||
"default": 25,
|
||||
"description": "The **maximum** number of Events that the cache should store triggered result summaries for\n\nThese summaries are viewable through the Web UI.\n\nThe value specified by a subreddit cannot be larger than the value set by the Operator for the global/bot config (if set)",
|
||||
"type": "number"
|
||||
},
|
||||
"authorTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
|
||||
@@ -1103,6 +1198,10 @@
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"prefix": {
|
||||
"description": "A prefix to add to all keys",
|
||||
"type": "string"
|
||||
},
|
||||
"store": {
|
||||
"$ref": "#/definitions/CacheProvider"
|
||||
},
|
||||
@@ -1635,6 +1734,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -1686,6 +1788,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2180,69 +2364,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterCriteriaDefaults": {
|
||||
"properties": {
|
||||
"authorIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AuthorCriteria"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
},
|
||||
"authorIsBehavior": {
|
||||
"enum": [
|
||||
"merge",
|
||||
"replace"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CommentState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
]
|
||||
},
|
||||
"itemIsBehavior": {
|
||||
"description": "Determine how itemIs defaults behave when itemIs is present on the check\n\n* merge => adds defaults to check's itemIs\n* replace => check itemIs will replace defaults (no defaults used)",
|
||||
"enum": [
|
||||
"merge",
|
||||
"replace"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterCriteriaDefaultsJson": {
|
||||
"properties": {
|
||||
"authorIs": {
|
||||
@@ -2313,62 +2434,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterOptions<AuthorCriteria>": {
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"excludeCondition": {
|
||||
"default": "OR",
|
||||
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"include": {
|
||||
"description": "Will \"pass\" if any set of Criteria passes",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterOptions<TypedActivityState>": {
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"excludeCondition": {
|
||||
"default": "OR",
|
||||
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"include": {
|
||||
"description": "Will \"pass\" if any set of Criteria passes",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterOptionsConfig<ActivityState>": {
|
||||
"properties": {
|
||||
"exclude": {
|
||||
@@ -2872,6 +2937,55 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalMHSConfig": {
|
||||
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
|
||||
"type": "boolean"
|
||||
},
|
||||
"totalMatching": {
|
||||
"default": "> 0",
|
||||
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
|
||||
"examples": [
|
||||
"> 0",
|
||||
"> 10%"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalMatching",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalSentimentConfig": {
|
||||
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
|
||||
"properties": {
|
||||
@@ -2942,6 +3056,40 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"ratio": {
|
||||
"properties": {
|
||||
"threshold": {
|
||||
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"submission": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
@@ -3341,6 +3489,126 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MHSCriteriaConfig": {
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
|
||||
"properties": {
|
||||
"confidence": {
|
||||
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
|
||||
"examples": [
|
||||
"> 50"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"flagged": {
|
||||
"default": true,
|
||||
"description": "Test if MHS considers content flagged as toxic or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"body"
|
||||
],
|
||||
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
|
||||
"items": {
|
||||
"enum": [
|
||||
"body",
|
||||
"title"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MHSRuleJSONConfig": {
|
||||
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
|
||||
"properties": {
|
||||
"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."
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"historical": {
|
||||
"$ref": "#/definitions/HistoricalMHSConfig",
|
||||
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
|
||||
},
|
||||
"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": {
|
||||
"default": "mhs",
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"mhs"
|
||||
],
|
||||
"examples": [
|
||||
"mhs"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"examples": [
|
||||
"myNewRule"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MessageActionJson": {
|
||||
"description": "Send a private message to the Author of the Activity.",
|
||||
"properties": {
|
||||
@@ -3492,18 +3760,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3542,6 +3810,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -3588,14 +3859,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -3646,6 +3909,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3718,18 +3996,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3788,6 +4066,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -5286,6 +5567,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -5362,7 +5646,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"filterCriteriaDefaults": {
|
||||
"$ref": "#/definitions/FilterCriteriaDefaults",
|
||||
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
|
||||
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -5678,6 +5962,25 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SharingACLConfig": {
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"description": "A list of subreddits, or regular expressions for subreddit names, that are NOT allowed to access this config\n\nIf `include` is defined this property is ignored",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"include": {
|
||||
"description": "A list of subreddits, or regular expressions for subreddit names, that are allowed to access this config",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionActionJson": {
|
||||
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
|
||||
"properties": {
|
||||
@@ -6079,6 +6382,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -6130,6 +6436,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -6180,6 +6568,23 @@
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link_flair_background_color": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_css_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -6195,7 +6600,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_text": {
|
||||
"anyOf": [
|
||||
@@ -6212,7 +6617,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
@@ -6341,6 +6746,17 @@
|
||||
"ThirdPartyCredentialsJsonConfig": {
|
||||
"additionalProperties": {},
|
||||
"properties": {
|
||||
"mhs": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"youtube": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
@@ -6473,7 +6889,7 @@
|
||||
"properties": {
|
||||
"allowDuplicate": {
|
||||
"default": false,
|
||||
"description": "Add Note even if a Note already exists for this Activity",
|
||||
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
@@ -6529,6 +6945,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -6596,6 +7027,23 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -6780,6 +7228,23 @@
|
||||
},
|
||||
"minItems": 1,
|
||||
"type": "array"
|
||||
},
|
||||
"sharing": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SharingACLConfig"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"description": "Enables config sharing\n\n* (Default) When `false` sharing is not enabled\n* When `true` any bot that can access this bot's config wiki page can use inpm t\n* When an object, use `include` or `exclude` to define subreddits that can access this config"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
|
||||
@@ -42,6 +42,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -595,6 +677,23 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The (user) flair background color (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css background color is assigned\n* If `false` then passes if NO css background is assigned\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -610,7 +709,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"red"
|
||||
]
|
||||
@@ -630,7 +729,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairText": {
|
||||
"anyOf": [
|
||||
@@ -647,7 +746,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"Approved"
|
||||
]
|
||||
@@ -679,10 +778,11 @@
|
||||
"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\"]",
|
||||
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
"AnotherUser",
|
||||
"/.*Foxx./*i"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -1458,6 +1558,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -1509,6 +1612,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2467,6 +2652,55 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalMHSConfig": {
|
||||
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
|
||||
"type": "boolean"
|
||||
},
|
||||
"totalMatching": {
|
||||
"default": "> 0",
|
||||
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
|
||||
"examples": [
|
||||
"> 0",
|
||||
"> 10%"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalMatching",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalSentimentConfig": {
|
||||
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
|
||||
"properties": {
|
||||
@@ -2537,6 +2771,40 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"ratio": {
|
||||
"properties": {
|
||||
"threshold": {
|
||||
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"submission": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
@@ -2936,6 +3204,126 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MHSCriteriaConfig": {
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
|
||||
"properties": {
|
||||
"confidence": {
|
||||
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
|
||||
"examples": [
|
||||
"> 50"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"flagged": {
|
||||
"default": true,
|
||||
"description": "Test if MHS considers content flagged as toxic or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"body"
|
||||
],
|
||||
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
|
||||
"items": {
|
||||
"enum": [
|
||||
"body",
|
||||
"title"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MHSRuleJSONConfig": {
|
||||
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
|
||||
"properties": {
|
||||
"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."
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"historical": {
|
||||
"$ref": "#/definitions/HistoricalMHSConfig",
|
||||
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
|
||||
},
|
||||
"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": {
|
||||
"default": "mhs",
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"mhs"
|
||||
],
|
||||
"examples": [
|
||||
"mhs"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"examples": [
|
||||
"myNewRule"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MessageActionJson": {
|
||||
"description": "Send a private message to the Author of the Activity.",
|
||||
"properties": {
|
||||
@@ -3087,18 +3475,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3137,6 +3525,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -3183,14 +3574,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -3241,6 +3624,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3313,18 +3711,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3383,6 +3781,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -4741,6 +5142,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -5404,6 +5808,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -5455,6 +5862,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -5505,6 +5994,23 @@
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link_flair_background_color": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_css_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -5520,7 +6026,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_text": {
|
||||
"anyOf": [
|
||||
@@ -5537,7 +6043,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
@@ -5781,7 +6287,7 @@
|
||||
"properties": {
|
||||
"allowDuplicate": {
|
||||
"default": false,
|
||||
"description": "Add Note even if a Note already exists for this Activity",
|
||||
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
@@ -5837,6 +6343,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -5904,6 +6425,23 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -49,6 +49,23 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The (user) flair background color (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css background color is assigned\n* If `false` then passes if NO css background is assigned\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -64,7 +81,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"red"
|
||||
]
|
||||
@@ -84,7 +101,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairText": {
|
||||
"anyOf": [
|
||||
@@ -101,7 +118,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"Approved"
|
||||
]
|
||||
@@ -133,10 +150,11 @@
|
||||
"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\"]",
|
||||
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
"AnotherUser",
|
||||
"/.*Foxx./*i"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -186,6 +204,17 @@
|
||||
},
|
||||
"BotCredentialsJsonConfig": {
|
||||
"properties": {
|
||||
"mhs": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"reddit": {
|
||||
"$ref": "#/definitions/RedditCredentials"
|
||||
},
|
||||
@@ -462,6 +491,10 @@
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"prefix": {
|
||||
"description": "A prefix to add to all keys",
|
||||
"type": "string"
|
||||
},
|
||||
"store": {
|
||||
"$ref": "#/definitions/CacheProvider"
|
||||
},
|
||||
@@ -522,6 +555,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -874,8 +989,18 @@
|
||||
"credentials": {
|
||||
"$ref": "#/definitions/InfluxCredentials"
|
||||
},
|
||||
"debug": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultTags": {
|
||||
"$ref": "#/definitions/Record<string,string>"
|
||||
},
|
||||
"useKeepAliveAgent": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"writeOptions": {
|
||||
"$ref": "#/definitions/WriteOptions",
|
||||
"description": "Options used by{@linkWriteApi}."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -934,7 +1059,7 @@
|
||||
"file": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"dirname\"|\"options\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">"
|
||||
"$ref": "#/definitions/Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"options\"|\"dirname\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
@@ -1039,18 +1164,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1089,6 +1214,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -1140,18 +1268,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1210,6 +1338,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -1381,7 +1512,7 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"dirname\"|\"options\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">": {
|
||||
"Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"options\"|\"dirname\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">": {
|
||||
"properties": {
|
||||
"auditFile": {
|
||||
"description": "A string representing the name of the name of the audit file. (default: './hash-audit.json')",
|
||||
@@ -1471,16 +1602,6 @@
|
||||
},
|
||||
"OperatorCacheConfig": {
|
||||
"properties": {
|
||||
"actionedEventsDefault": {
|
||||
"default": 25,
|
||||
"description": "The **default** number of Events that the cache will store triggered result summaries for\n\nThese summaries are viewable through the Web UI.\n\nThe value specified cannot be larger than `actionedEventsMax` for the global/bot config (if set)",
|
||||
"type": "number"
|
||||
},
|
||||
"actionedEventsMax": {
|
||||
"default": 25,
|
||||
"description": "The **maximum** number of Events that the cache should store triggered result summaries for\n\nThese summaries are viewable through the Web UI.\n\nThe value specified by a subreddit cannot be larger than the value set by the Operator for the global/bot config (if set)",
|
||||
"type": "number"
|
||||
},
|
||||
"authorTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
|
||||
@@ -1834,6 +1955,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1884,6 +2087,23 @@
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link_flair_background_color": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_css_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -1899,7 +2119,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_text": {
|
||||
"anyOf": [
|
||||
@@ -1916,7 +2136,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
@@ -2048,6 +2268,17 @@
|
||||
"ThirdPartyCredentialsJsonConfig": {
|
||||
"additionalProperties": {},
|
||||
"properties": {
|
||||
"mhs": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"youtube": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
@@ -2073,6 +2304,23 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -2132,6 +2380,94 @@
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"WriteOptions": {
|
||||
"description": "Options used by{@linkWriteApi}.",
|
||||
"properties": {
|
||||
"batchSize": {
|
||||
"description": "max number of records/lines to send in a batch",
|
||||
"type": "number"
|
||||
},
|
||||
"consistency": {
|
||||
"description": "InfluxDB Enterprise write consistency as explained in https://docs.influxdata.com/enterprise_influxdb/v1.9/concepts/clustering/#write-consistency",
|
||||
"enum": [
|
||||
"all",
|
||||
"any",
|
||||
"one",
|
||||
"quorum"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"defaultTags": {
|
||||
"$ref": "#/definitions/Record<string,string>",
|
||||
"description": "default tags, unescaped"
|
||||
},
|
||||
"exponentialBase": {
|
||||
"description": "base for the exponential retry delay",
|
||||
"type": "number"
|
||||
},
|
||||
"flushInterval": {
|
||||
"description": "delay between data flushes in milliseconds, at most `batch size` records are sent during flush",
|
||||
"type": "number"
|
||||
},
|
||||
"gzipThreshold": {
|
||||
"description": "When specified, write bodies larger than the threshold are gzipped",
|
||||
"type": "number"
|
||||
},
|
||||
"headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "HTTP headers that will be sent with every write request",
|
||||
"type": "object"
|
||||
},
|
||||
"maxBatchBytes": {
|
||||
"description": "max size of a batch in bytes",
|
||||
"type": "number"
|
||||
},
|
||||
"maxBufferLines": {
|
||||
"description": "the maximum size of retry-buffer (in lines)",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRetries": {
|
||||
"description": "max count of retries after the first write fails",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRetryDelay": {
|
||||
"description": "maximum delay when retrying write (milliseconds)",
|
||||
"type": "number"
|
||||
},
|
||||
"maxRetryTime": {
|
||||
"description": "max time (millis) that can be spent with retries",
|
||||
"type": "number"
|
||||
},
|
||||
"minRetryDelay": {
|
||||
"description": "minimum delay when retrying write (milliseconds)",
|
||||
"type": "number"
|
||||
},
|
||||
"randomRetry": {
|
||||
"description": "randomRetry indicates whether the next retry delay is deterministic (false) or random (true).\nThe deterministic delay starts with `minRetryDelay * exponentialBase` and it is multiplied\nby `exponentialBase` until it exceeds `maxRetryDelay`.\nWhen random is `true`, the next delay is computed as a random number between next retry attempt (upper)\nand the lower number in the deterministic sequence. `random(retryJitter)` is added to every returned value.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"retryJitter": {
|
||||
"description": "add `random(retryJitter)` milliseconds delay when retrying HTTP calls",
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"batchSize",
|
||||
"exponentialBase",
|
||||
"flushInterval",
|
||||
"maxBatchBytes",
|
||||
"maxBufferLines",
|
||||
"maxRetries",
|
||||
"maxRetryDelay",
|
||||
"maxRetryTime",
|
||||
"minRetryDelay",
|
||||
"randomRetry",
|
||||
"retryJitter"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "Configuration for application-level settings IE for running the bot instance\n\n* To load a JSON configuration **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`\n* To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`",
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -60,6 +63,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -510,6 +595,23 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The (user) flair background color (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css background color is assigned\n* If `false` then passes if NO css background is assigned\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -525,7 +627,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"red"
|
||||
]
|
||||
@@ -545,7 +647,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairText": {
|
||||
"anyOf": [
|
||||
@@ -562,7 +664,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"Approved"
|
||||
]
|
||||
@@ -594,10 +696,11 @@
|
||||
"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\"]",
|
||||
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
"AnotherUser",
|
||||
"/.*Foxx./*i"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -763,6 +866,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1316,6 +1501,55 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalMHSConfig": {
|
||||
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
|
||||
"type": "boolean"
|
||||
},
|
||||
"totalMatching": {
|
||||
"default": "> 0",
|
||||
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
|
||||
"examples": [
|
||||
"> 0",
|
||||
"> 10%"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalMatching",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalSentimentConfig": {
|
||||
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
|
||||
"properties": {
|
||||
@@ -1386,6 +1620,40 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"ratio": {
|
||||
"properties": {
|
||||
"threshold": {
|
||||
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"submission": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
@@ -1693,6 +1961,126 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MHSCriteriaConfig": {
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
|
||||
"properties": {
|
||||
"confidence": {
|
||||
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
|
||||
"examples": [
|
||||
"> 50"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"flagged": {
|
||||
"default": true,
|
||||
"description": "Test if MHS considers content flagged as toxic or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"body"
|
||||
],
|
||||
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
|
||||
"items": {
|
||||
"enum": [
|
||||
"body",
|
||||
"title"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MHSRuleJSONConfig": {
|
||||
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
|
||||
"properties": {
|
||||
"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."
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"historical": {
|
||||
"$ref": "#/definitions/HistoricalMHSConfig",
|
||||
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
|
||||
},
|
||||
"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": {
|
||||
"default": "mhs",
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"mhs"
|
||||
],
|
||||
"examples": [
|
||||
"mhs"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"examples": [
|
||||
"myNewRule"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModLogCriteria": {
|
||||
"properties": {
|
||||
"action": {
|
||||
@@ -1714,18 +2102,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1764,6 +2152,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -1815,18 +2206,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1885,6 +2276,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -3241,6 +3635,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -3291,6 +3767,23 @@
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link_flair_background_color": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_css_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3306,7 +3799,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_text": {
|
||||
"anyOf": [
|
||||
@@ -3323,7 +3816,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
@@ -3469,6 +3962,23 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -28,6 +28,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -478,6 +560,23 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The (user) flair background color (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css background color is assigned\n* If `false` then passes if NO css background is assigned\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -493,7 +592,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"red"
|
||||
]
|
||||
@@ -513,7 +612,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairText": {
|
||||
"anyOf": [
|
||||
@@ -530,7 +629,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"Approved"
|
||||
]
|
||||
@@ -562,10 +661,11 @@
|
||||
"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\"]",
|
||||
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
"AnotherUser",
|
||||
"/.*Foxx./*i"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -731,6 +831,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1284,6 +1466,55 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalMHSConfig": {
|
||||
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
|
||||
"type": "boolean"
|
||||
},
|
||||
"totalMatching": {
|
||||
"default": "> 0",
|
||||
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
|
||||
"examples": [
|
||||
"> 0",
|
||||
"> 10%"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalMatching",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalSentimentConfig": {
|
||||
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
|
||||
"properties": {
|
||||
@@ -1354,6 +1585,40 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"ratio": {
|
||||
"properties": {
|
||||
"threshold": {
|
||||
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"submission": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
@@ -1661,6 +1926,126 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MHSCriteriaConfig": {
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
|
||||
"properties": {
|
||||
"confidence": {
|
||||
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
|
||||
"examples": [
|
||||
"> 50"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"flagged": {
|
||||
"default": true,
|
||||
"description": "Test if MHS considers content flagged as toxic or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"body"
|
||||
],
|
||||
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
|
||||
"items": {
|
||||
"enum": [
|
||||
"body",
|
||||
"title"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MHSRuleJSONConfig": {
|
||||
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
|
||||
"properties": {
|
||||
"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."
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"historical": {
|
||||
"$ref": "#/definitions/HistoricalMHSConfig",
|
||||
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
|
||||
},
|
||||
"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": {
|
||||
"default": "mhs",
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"mhs"
|
||||
],
|
||||
"examples": [
|
||||
"mhs"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"examples": [
|
||||
"myNewRule"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ModLogCriteria": {
|
||||
"properties": {
|
||||
"action": {
|
||||
@@ -1682,18 +2067,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1732,6 +2117,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -1783,18 +2171,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -1853,6 +2241,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -3209,6 +3600,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -3259,6 +3732,23 @@
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link_flair_background_color": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_css_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3274,7 +3764,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_text": {
|
||||
"anyOf": [
|
||||
@@ -3291,7 +3781,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
@@ -3437,6 +3927,23 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -3509,6 +4016,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -592,6 +674,23 @@
|
||||
]
|
||||
]
|
||||
},
|
||||
"flairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "The (user) flair background color (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css background color is assigned\n* If `false` then passes if NO css background is assigned\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -607,7 +706,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned",
|
||||
"description": "A (user) flair css class (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY css is assigned\n* If `false` then passes if NO css is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"red"
|
||||
]
|
||||
@@ -627,7 +726,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned"
|
||||
"description": "A (user) flair template id (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY template is assigned\n* If `false` then passed if NO template is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"flairText": {
|
||||
"anyOf": [
|
||||
@@ -644,7 +743,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned",
|
||||
"description": "A (user) flair text value (or list of) from the subreddit to match against\n\n* If `true` then passes if ANY text is assigned\n* If `false` then passes if NO text is assigned\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.",
|
||||
"examples": [
|
||||
"Approved"
|
||||
]
|
||||
@@ -676,10 +775,11 @@
|
||||
"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\"]",
|
||||
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
|
||||
"examples": [
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
"AnotherUser",
|
||||
"/.*Foxx./*i"
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
@@ -1455,6 +1555,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -1506,6 +1609,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -1961,13 +2146,10 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterCriteriaDefaults": {
|
||||
"FilterCriteriaDefaultsJson": {
|
||||
"properties": {
|
||||
"authorIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
@@ -1976,12 +2158,19 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
|
||||
}
|
||||
]
|
||||
],
|
||||
"description": "Determine how authorIs defaults behave when authorIs is present on the check\n\n* merge => merges defaults with check's authorIs\n* replace => check authorIs will replace defaults (no defaults used)"
|
||||
},
|
||||
"authorIsBehavior": {
|
||||
"enum": [
|
||||
@@ -1992,9 +2181,6 @@
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
@@ -2006,10 +2192,16 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2024,62 +2216,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterOptions<AuthorCriteria>": {
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"excludeCondition": {
|
||||
"default": "OR",
|
||||
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"include": {
|
||||
"description": "Will \"pass\" if any set of Criteria passes",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterOptions<TypedActivityState>": {
|
||||
"properties": {
|
||||
"exclude": {
|
||||
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"excludeCondition": {
|
||||
"default": "OR",
|
||||
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
|
||||
"enum": [
|
||||
"AND",
|
||||
"OR"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"include": {
|
||||
"description": "Will \"pass\" if any set of Criteria passes",
|
||||
"items": {
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterOptionsConfig<ActivityState>": {
|
||||
"properties": {
|
||||
"exclude": {
|
||||
@@ -2583,6 +2719,55 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalMHSConfig": {
|
||||
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
|
||||
"properties": {
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
|
||||
"type": "boolean"
|
||||
},
|
||||
"totalMatching": {
|
||||
"default": "> 0",
|
||||
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
|
||||
"examples": [
|
||||
"> 0",
|
||||
"> 10%"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"totalMatching",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HistoricalSentimentConfig": {
|
||||
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
|
||||
"properties": {
|
||||
@@ -2653,6 +2838,40 @@
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"ratio": {
|
||||
"properties": {
|
||||
"threshold": {
|
||||
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"window": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/DurationObject"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FullActivityWindowConfig"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"number"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
|
||||
"examples": [
|
||||
"90 days"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"threshold",
|
||||
"window"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"submission": {
|
||||
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
@@ -3052,6 +3271,126 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MHSCriteriaConfig": {
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
|
||||
"properties": {
|
||||
"confidence": {
|
||||
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
|
||||
"examples": [
|
||||
"> 50"
|
||||
],
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
|
||||
"type": "string"
|
||||
},
|
||||
"flagged": {
|
||||
"default": true,
|
||||
"description": "Test if MHS considers content flagged as toxic or not",
|
||||
"type": "boolean"
|
||||
},
|
||||
"testOn": {
|
||||
"default": [
|
||||
"body"
|
||||
],
|
||||
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
|
||||
"items": {
|
||||
"enum": [
|
||||
"body",
|
||||
"title"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MHSRuleJSONConfig": {
|
||||
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
|
||||
"properties": {
|
||||
"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."
|
||||
},
|
||||
"criteria": {
|
||||
"$ref": "#/definitions/MHSCriteriaConfig",
|
||||
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
|
||||
},
|
||||
"historical": {
|
||||
"$ref": "#/definitions/HistoricalMHSConfig",
|
||||
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
|
||||
},
|
||||
"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": {
|
||||
"default": "mhs",
|
||||
"description": "The kind of rule to run",
|
||||
"enum": [
|
||||
"mhs"
|
||||
],
|
||||
"examples": [
|
||||
"mhs"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
|
||||
"examples": [
|
||||
"myNewRule"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MessageActionJson": {
|
||||
"description": "Send a private message to the Author of the Activity.",
|
||||
"properties": {
|
||||
@@ -3203,18 +3542,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3253,6 +3592,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -3299,14 +3641,6 @@
|
||||
"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": [
|
||||
{
|
||||
@@ -3357,6 +3691,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -3429,18 +3778,18 @@
|
||||
"items": {
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"comment",
|
||||
false,
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -3499,6 +3848,9 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
@@ -4857,6 +5209,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4933,7 +5288,7 @@
|
||||
"type": "boolean"
|
||||
},
|
||||
"filterCriteriaDefaults": {
|
||||
"$ref": "#/definitions/FilterCriteriaDefaults",
|
||||
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
|
||||
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
|
||||
},
|
||||
"itemIs": {
|
||||
@@ -5650,6 +6005,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -5701,6 +6059,88 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"authorFlairBackgroundColor": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairCssClass": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairTemplateId": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"authorFlairText": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"createdOn": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
|
||||
},
|
||||
"deleted": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -5751,6 +6191,23 @@
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"link_flair_background_color": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": [
|
||||
"string",
|
||||
"boolean"
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_css_class": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -5766,7 +6223,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
|
||||
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"link_flair_text": {
|
||||
"anyOf": [
|
||||
@@ -5783,7 +6240,7 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
|
||||
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
|
||||
},
|
||||
"locked": {
|
||||
"type": "boolean"
|
||||
@@ -6027,7 +6484,7 @@
|
||||
"properties": {
|
||||
"allowDuplicate": {
|
||||
"default": false,
|
||||
"description": "Add Note even if a Note already exists for this Activity",
|
||||
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
@@ -6083,6 +6540,21 @@
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"existingNoteCheck": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/UserNoteCriteria"
|
||||
},
|
||||
{
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"default": true,
|
||||
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -6150,6 +6622,23 @@
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
|
||||
},
|
||||
"referencesCurrentActivity": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"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",
|
||||
|
||||
@@ -9,8 +9,15 @@ import {
|
||||
createRetryHandler,
|
||||
determineNewResults,
|
||||
findLastIndex,
|
||||
formatNumber, frequencyEqualOrLargerThanMin, getActivityAuthorName, isComment, isSubmission, likelyJson5,
|
||||
mergeArr, normalizeName,
|
||||
formatNumber,
|
||||
frequencyEqualOrLargerThanMin,
|
||||
generateFullWikiUrl,
|
||||
getActivityAuthorName,
|
||||
isComment,
|
||||
isSubmission,
|
||||
likelyJson5,
|
||||
mergeArr,
|
||||
normalizeName,
|
||||
parseRedditEntity,
|
||||
pollingInfo,
|
||||
resultsSummary,
|
||||
@@ -46,10 +53,7 @@ import {Submission, Comment, Subreddit} from 'snoowrap/dist/objects';
|
||||
import {activityIsRemoved, ItemContent, itemContentPeek} from "../Utils/SnoowrapUtils";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
BotResourcesManager,
|
||||
SubredditResourceConfig,
|
||||
SubredditResources,
|
||||
SubredditResourceSetOptions
|
||||
SubredditResources
|
||||
} from "./SubredditResources";
|
||||
import {SPoll, UnmoderatedStream, ModQueueStream, SubmissionStream, CommentStream} from "./Streams";
|
||||
import EventEmitter from "events";
|
||||
@@ -67,7 +71,7 @@ import {
|
||||
isRateLimitError,
|
||||
isSeriousError,
|
||||
isStatusError,
|
||||
RunProcessingError
|
||||
RunProcessingError, SimpleError
|
||||
} from "../Utils/Errors";
|
||||
import {ErrorWithCause, stackWithCauses} from "pony-cause";
|
||||
import {Run} from "../Run";
|
||||
@@ -87,8 +91,7 @@ import {InvokeeType} from "../Common/Entities/InvokeeType";
|
||||
import {RunStateType} from "../Common/Entities/RunStateType";
|
||||
import {EntityRunState} from "../Common/Entities/EntityRunState/EntityRunState";
|
||||
import {
|
||||
ActivitySource,
|
||||
DispatchSource,
|
||||
ActivitySourceValue,
|
||||
EventRetentionPolicyRange,
|
||||
Invokee,
|
||||
PollOn,
|
||||
@@ -101,6 +104,9 @@ import {InfluxClient} from "../Common/Influx/InfluxClient";
|
||||
import { Point } from "@influxdata/influxdb-client";
|
||||
import {NormalizedManagerResponse} from "../Web/Common/interfaces";
|
||||
import {guestEntityToApiGuest} from "../Common/Entities/Guest/GuestEntity";
|
||||
import {BotResourcesManager} from "../Bot/ResourcesManager";
|
||||
import {SubredditResourceConfig} from "../Common/Subreddit/SubredditResourceInterfaces";
|
||||
import objectHash from "object-hash";
|
||||
|
||||
export interface RunningState {
|
||||
state: RunState,
|
||||
@@ -121,7 +127,7 @@ export interface runCheckOptions {
|
||||
force?: boolean,
|
||||
gotoContext?: string
|
||||
maxGotoDepth?: number
|
||||
source: ActivitySource
|
||||
source: ActivitySourceValue
|
||||
initialGoto?: string
|
||||
activitySource: ActivitySourceData
|
||||
disableDispatchDelays?: boolean
|
||||
@@ -204,6 +210,7 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
|
||||
startedAt?: DayjsObj;
|
||||
validConfigLoaded: boolean = false;
|
||||
lastParseConfigHash?: string;
|
||||
|
||||
eventsState: RunningState = {
|
||||
state: STOPPED,
|
||||
@@ -267,8 +274,8 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
data.historical = this.resources.getHistoricalDisplayStats();
|
||||
data.cache = resStats.cache;
|
||||
data.cache.currentKeyCount = await this.resources.getCacheKeyCount();
|
||||
data.cache.isShared = this.resources.cacheSettingsHash === 'default';
|
||||
data.cache.provider = this.resources.cacheType;
|
||||
data.cache.isShared = this.resources.cache.isDefaultCache;
|
||||
data.cache.provider = this.resources.cache.providerOptions.store;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
@@ -375,7 +382,7 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
|
||||
this.eventsSampleInterval = setInterval((function(self) {
|
||||
return function() {
|
||||
const et = self.resources !== undefined ? self.resources.stats.historical.eventsCheckedTotal : 0;
|
||||
const et = self.resources !== undefined ? self.resources.subredditStats.stats.historical.eventsCheckedTotal : 0;
|
||||
const rollingSample = self.eventsSample.slice(0, 7)
|
||||
rollingSample.unshift(et)
|
||||
self.eventsSample = rollingSample;
|
||||
@@ -397,7 +404,7 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
this.rulesUniqueSampleInterval = setInterval((function(self) {
|
||||
return function() {
|
||||
const rollingSample = self.rulesUniqueSample.slice(0, 7)
|
||||
const rt = self.resources !== undefined ? self.resources.stats.historical.rulesRunTotal - self.resources.stats.historical.rulesCachedTotal : 0;
|
||||
const rt = self.resources !== undefined ? self.resources.subredditStats.stats.historical.rulesRunTotal - self.resources.subredditStats.stats.historical.rulesCachedTotal : 0;
|
||||
rollingSample.unshift(rt);
|
||||
self.rulesUniqueSample = rollingSample;
|
||||
const diff = self.rulesUniqueSample.reduceRight((acc: number[], curr, index) => {
|
||||
@@ -595,6 +602,34 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
return this.runs.map(x => x.commentChecks);
|
||||
}
|
||||
|
||||
async setResourceManager(config: Partial<SubredditResourceConfig> = {}) {
|
||||
const {
|
||||
footer,
|
||||
logger = this.logger,
|
||||
subreddit = this.subreddit,
|
||||
caching,
|
||||
credentials,
|
||||
client = this.client,
|
||||
botEntity = this.botEntity,
|
||||
managerEntity = this.managerEntity,
|
||||
statFrequency = this.statDefaults.minFrequency,
|
||||
retention = this.retentionOverride,
|
||||
} = config;
|
||||
|
||||
this.resources = await this.cacheManager.set(this.subreddit.display_name, {
|
||||
footer: footer === undefined && this.resources !== undefined ? this.resources.footer : footer,
|
||||
logger,
|
||||
subreddit,
|
||||
caching,
|
||||
credentials,
|
||||
client,
|
||||
botEntity,
|
||||
managerEntity,
|
||||
statFrequency,
|
||||
retention,
|
||||
});
|
||||
}
|
||||
|
||||
protected async parseConfigurationFromObject(configObj: object, suppressChangeEvent: boolean = false) {
|
||||
try {
|
||||
const configBuilder = new ConfigBuilder({logger: this.logger});
|
||||
@@ -620,10 +655,6 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
|
||||
this.displayLabel = nickname || `${this.subreddit.display_name_prefixed}`;
|
||||
|
||||
if (footer !== undefined) {
|
||||
this.resources.footer = footer;
|
||||
}
|
||||
|
||||
this.subMaxWorkers = maxWorkers;
|
||||
const realMax = this.getMaxWorkers(this.subMaxWorkers);
|
||||
if(realMax !== this.queue.concurrency) {
|
||||
@@ -660,13 +691,19 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
statFrequency: realStatFrequency,
|
||||
retention: this.retentionOverride ?? retention
|
||||
};
|
||||
this.resources = await this.cacheManager.set(this.subreddit.display_name, resourceConfig);
|
||||
await this.setResourceManager(resourceConfig);
|
||||
this.resources.setLogger(this.logger);
|
||||
|
||||
if (footer !== undefined && this.resources !== undefined) {
|
||||
this.resources.footer = footer;
|
||||
}
|
||||
|
||||
this.logger.info('Subreddit-specific options updated');
|
||||
this.logger.info('Building Runs and Checks...');
|
||||
|
||||
const structuredRuns = await configBuilder.parseToStructured(validJson, this.resources, this.filterCriteriaDefaults, this.postCheckBehaviorDefaults);
|
||||
const hydratedConfig = await configBuilder.hydrateConfig(validJson, this.resources);
|
||||
this.lastParseConfigHash = objectHash.sha1(hydratedConfig);
|
||||
const structuredRuns = await configBuilder.parseToStructured(hydratedConfig, this.filterCriteriaDefaults, this.postCheckBehaviorDefaults);
|
||||
|
||||
let runs: Run[] = [];
|
||||
|
||||
@@ -771,87 +808,55 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
|
||||
async parseConfiguration(causedBy: Invokee = 'system', force: boolean = false, options?: ManagerStateChangeOption) {
|
||||
const {reason, suppressNotification = false, suppressChangeEvent = false} = options || {};
|
||||
if(this.resources === undefined) {
|
||||
await this.setResourceManager();
|
||||
}
|
||||
//this.wikiUpdateRunning = true;
|
||||
this.lastWikiCheck = dayjs();
|
||||
let wikiPageChanged = false;
|
||||
|
||||
try {
|
||||
let sourceData: string;
|
||||
let wiki: WikiPage;
|
||||
try {
|
||||
try {
|
||||
// @ts-ignore
|
||||
wiki = await this.subreddit.getWikiPage(this.wikiLocation).fetch();
|
||||
const {val, wikiPage} = await this.resources.getWikiPage({wiki: this.wikiLocation}, {force: true});
|
||||
wiki = wikiPage as WikiPage;
|
||||
//sourceData = val as string;
|
||||
} catch (err: any) {
|
||||
if(isStatusError(err) && err.statusCode === 404) {
|
||||
// see if we can create the page
|
||||
if (!this.client.scope.includes('wikiedit')) {
|
||||
throw new ErrorWithCause(`Page does not exist and could not be created because Bot does not have oauth permission 'wikiedit'`, {cause: err});
|
||||
if(err.cause !== undefined && isStatusError(err.cause) && err.cause.statusCode === 404) {
|
||||
// try to create it
|
||||
try {
|
||||
wiki = await this.writeConfig('', 'Empty configuration created for ContextMod');
|
||||
} catch (e: any) {
|
||||
throw new CMError(`Parsing config from wiki page failed because ${err.message} AND creating empty page failed`, {cause: e});
|
||||
}
|
||||
const modPermissions = await this.getModPermissions();
|
||||
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
|
||||
throw new ErrorWithCause(`Page does not exist and could not be created because Bot not have mod permissions for creating wiki pages. Must have 'all' or 'wiki'`, {cause: err});
|
||||
}
|
||||
if(!this.client.scope.includes('modwiki')) {
|
||||
throw new ErrorWithCause(`Bot COULD create wiki config page but WILL NOT because it does not have the oauth permissions 'modwiki' which is required to set page visibility and editing permissions. Safety first!`, {cause: err});
|
||||
}
|
||||
// @ts-ignore
|
||||
wiki = await this.subreddit.getWikiPage(this.wikiLocation).edit({
|
||||
text: '',
|
||||
reason: 'Empty configuration created for ContextMod'
|
||||
});
|
||||
this.logger.info(`Wiki page at ${this.wikiLocation} did not exist so bot created it!`);
|
||||
|
||||
// 0 = use subreddit wiki permissions
|
||||
// 1 = only approved wiki contributors
|
||||
// 2 = only mods may edit and view
|
||||
// @ts-ignore
|
||||
await this.subreddit.getWikiPage(this.wikiLocation).editSettings({
|
||||
permissionLevel: 2,
|
||||
// don't list this page on r/[subreddit]/wiki/pages
|
||||
listed: false,
|
||||
});
|
||||
this.logger.info('Bot set wiki page visibility to MODS ONLY');
|
||||
} else {
|
||||
throw err;
|
||||
throw new CMError('Reading config from wiki failed', {cause: err});
|
||||
}
|
||||
}
|
||||
const revisionDate = dayjs.unix(wiki.revision_date);
|
||||
if (!force && this.validConfigLoaded && (this.lastWikiRevision !== undefined && this.lastWikiRevision.isSame(revisionDate))) {
|
||||
// nothing to do, we already have this revision
|
||||
//this.wikiUpdateRunning = false;
|
||||
if (force) {
|
||||
this.logger.info('Config is up to date');
|
||||
|
||||
if(this.lastWikiRevision !== undefined) {
|
||||
if(this.lastWikiRevision.isSame(revisionDate)) {
|
||||
this.logger.verbose('Config wiki has not changed since last check, going ahead with other checks...');
|
||||
} else {
|
||||
wikiPageChanged = true;
|
||||
this.logger.info(`Updating config due to stale wiki page (${dayjs.duration(dayjs().diff(revisionDate)).humanize()} old)`)
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (force) {
|
||||
this.logger.info('Config update was forced');
|
||||
} else if (!this.validConfigLoaded) {
|
||||
} else {
|
||||
this.logger.info('Trying to load (new?) config now since there is no valid config loaded');
|
||||
} else if (this.lastWikiRevision !== undefined) {
|
||||
this.logger.info(`Updating config due to stale wiki page (${dayjs.duration(dayjs().diff(revisionDate)).humanize()} old)`)
|
||||
}
|
||||
|
||||
if(this.queueState.state === RUNNING) {
|
||||
this.logger.verbose('Waiting for activity processing queue to pause before continuing config update');
|
||||
await this.pauseQueue(causedBy);
|
||||
}
|
||||
|
||||
this.lastWikiRevision = revisionDate;
|
||||
sourceData = await wiki.content_md;
|
||||
} catch (err: any) {
|
||||
let hint = '';
|
||||
if(isStatusError(err) && err.statusCode === 403) {
|
||||
hint = ` -- HINT: Either the page is restricted to mods only and the bot's reddit account does have the mod permission 'all' or 'wiki' OR the bot does not have the 'wikiread' oauth permission`;
|
||||
}
|
||||
const msg = `Could not read wiki configuration. Please ensure the page https://reddit.com${this.subreddit.url}wiki/${this.wikiLocation} exists and is readable${hint}`;
|
||||
throw new ErrorWithCause(msg, {cause: err});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (sourceData.replace('\r\n', '').trim() === '') {
|
||||
this.logger.error(`Wiki page contents was empty`);
|
||||
throw new ConfigParseError('Wiki page contents was empty');
|
||||
this.logger.error(`Wiki page contents is empty. The bot cannot run until this subreddit's wiki page has a valid config added!`);
|
||||
throw new ConfigParseError(`Wiki page contents is empty. The bot cannot run until this subreddit's wiki page has a valid config added!`);
|
||||
}
|
||||
|
||||
const [format, configObj, jsonErr, yamlErr] = parseFromJsonOrYamlToObject(sourceData);
|
||||
@@ -871,6 +876,23 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
throw new ConfigParseError('Could not parse wiki page contents as JSON or YAML')
|
||||
}
|
||||
|
||||
if (!wikiPageChanged && this.validConfigLoaded && this.lastParseConfigHash !== undefined && !force) {
|
||||
// need to check if hydrated is different from current
|
||||
const hydratedRuns = await this.buildHydratedRuns(configObj.toJS());
|
||||
const hydratedHash = objectHash.sha1(hydratedRuns);
|
||||
if (hydratedHash === this.lastParseConfigHash) {
|
||||
this.logger.info('Config is up to date');
|
||||
return false;
|
||||
} else {
|
||||
this.logger.info('Hydrated config differed from wiki contents, continuing with update.');
|
||||
}
|
||||
}
|
||||
|
||||
if(this.queueState.state === RUNNING) {
|
||||
this.logger.verbose('Waiting for activity processing queue to pause before continuing config update');
|
||||
await this.pauseQueue(causedBy);
|
||||
}
|
||||
|
||||
await this.parseConfigurationFromObject(configObj.toJS(), suppressChangeEvent);
|
||||
this.logger.info('Checks updated');
|
||||
|
||||
@@ -880,15 +902,22 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
const error = new ErrorWithCause('Failed to parse subreddit configuration', {cause: err});
|
||||
// @ts-ignore
|
||||
//error.logged = true;
|
||||
this.logger.error(error);
|
||||
if(this.resources === undefined) {
|
||||
// if we fail to get a valid config and there is no existing resource then just create a default one
|
||||
// -- also ensures that if one already exists we don't overwrite it
|
||||
await this.setResourceManager()
|
||||
}
|
||||
this.validConfigLoaded = false;
|
||||
throw error;
|
||||
throw new ErrorWithCause('Failed to parse subreddit configuration', {cause: err});
|
||||
}
|
||||
}
|
||||
|
||||
async buildHydratedRuns(configObj: object) {
|
||||
const configBuilder = new ConfigBuilder({logger: this.logger});
|
||||
const validJson = configBuilder.validateJson(configObj);
|
||||
return await configBuilder.hydrateConfig(validJson, this.resources);
|
||||
}
|
||||
|
||||
async handleActivity(activity: (Submission | Comment), options: runCheckOptions): Promise<void> {
|
||||
const checkType = isSubmission(activity) ? 'Submission' : 'Comment';
|
||||
let item = activity,
|
||||
@@ -966,7 +995,7 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
const itemId = await item.id;
|
||||
|
||||
if(await this.resources.hasRecentSelf(item)) {
|
||||
let recentMsg = `Found in Activities recently (last ${this.resources.selfTTL} seconds) modified/created by this bot`;
|
||||
let recentMsg = `Found in Activities recently (last ${this.resources.ttl.selfTTL} seconds) modified/created by this bot`;
|
||||
if(force) {
|
||||
this.logger.debug(`${recentMsg} but will run anyway because "force" option was true.`);
|
||||
} else {
|
||||
@@ -1799,6 +1828,77 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
}
|
||||
}
|
||||
|
||||
async setWikiPermissions(location: string = this.wikiLocation) {
|
||||
if(!this.client.scope.includes('modwiki')) {
|
||||
throw new SimpleError(`Cannot check or set permissions for wiki because bot does not have the 'modwiki' oauth permission`);
|
||||
}
|
||||
|
||||
const settings = await this.subreddit.getWikiPage(location).getSettings();
|
||||
const reasons = [];
|
||||
if(settings.listed) {
|
||||
reasons.push(`Page is listed (visible from r/${this.subreddit.display_name}/wiki/pages) but should be delisted.`)
|
||||
}
|
||||
// 0 = use subreddit wiki permissions
|
||||
// 1 = only approved wiki contributors
|
||||
// 2 = only mods may edit and view
|
||||
if(settings.permissionLevel === 0) {
|
||||
reasons.push(`Page editing level is set to 'inherit from general wiki settings' but should be set to contributors/mods only`);
|
||||
}
|
||||
if (reasons.length > 0) {
|
||||
this.logger.debug(`Updating wiki page permissions because: ${reasons.join(' | ')}`)
|
||||
// @ts-ignore
|
||||
await this.subreddit.getWikiPage(location).editSettings({
|
||||
permissionLevel: 2,
|
||||
// don't list this page on r/[subreddit]/wiki/pages
|
||||
listed: false,
|
||||
});
|
||||
this.logger.info('Bot set wiki page visibility to MODS ONLY and delisted the page');
|
||||
}
|
||||
}
|
||||
|
||||
async writeConfig(data: string, reason?: string, location: string = this.wikiLocation) {
|
||||
|
||||
const oauthErrors = [];
|
||||
if (!this.client.scope.includes('wikiedit')) {
|
||||
oauthErrors.push(`missing oauth permission 'wikiedit' is required to edit wiki pages`);
|
||||
}
|
||||
if (!this.client.scope.includes('modwiki')) {
|
||||
oauthErrors.push(`missing oauth permission 'modwiki' which is required to set page visibility and editing permissions.`);
|
||||
}
|
||||
|
||||
if(oauthErrors.length > 0) {
|
||||
throw new SimpleError(`Cannot edit wiki page ${generateFullWikiUrl(this.subreddit, location)} because: ${oauthErrors.join(' | ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const wiki = await this.subreddit.getWikiPage(location).edit({
|
||||
text: data,
|
||||
reason: reason,
|
||||
});
|
||||
this.logger.debug(`Wrote config to ${location}`);
|
||||
try {
|
||||
await this.setWikiPermissions(location);
|
||||
} catch (e: any) {
|
||||
if (e.message.includes('modwiki')) {
|
||||
this.logger.warn(e);
|
||||
} else {
|
||||
throw new CMError(`Successfully edited wiki page ${generateFullWikiUrl(this.subreddit, location)} but an error occurred while checking/setting page permissions`, {cause: e});
|
||||
}
|
||||
}
|
||||
return wiki;
|
||||
} catch (err: any) {
|
||||
if (isStatusError(err)) {
|
||||
const modPermissions = await this.getModPermissions();
|
||||
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
|
||||
throw new ErrorWithCause(`Could not create wiki page ${generateFullWikiUrl(this.subreddit, location)} because Bot not have mod permissions for creating wiki pages. Must have 'all' or 'wiki'`, {cause: err});
|
||||
}
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toNormalizedManager(): NormalizedManagerResponse {
|
||||
return {
|
||||
name: this.displayLabel,
|
||||
|
||||