Compare commits
61 Commits
imageCompa
...
modlog
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
381976d6af | ||
|
|
3faf4ca3dc | ||
|
|
2f35b82d5e | ||
|
|
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 | ||
|
|
77856a6d97 | ||
|
|
b216cd08e1 | ||
|
|
052c1218c6 | ||
|
|
c2343683bb | ||
|
|
fcf718f1d0 | ||
|
|
77f848007a | ||
|
|
95216b3950 | ||
|
|
58a21e8d05 | ||
|
|
49ac8cda19 | ||
|
|
bc8be3608b | ||
|
|
1b69cd78bb | ||
|
|
e736379f85 | ||
|
|
c0e1a93fb4 | ||
|
|
bd35b06ebf | ||
|
|
f852e85234 | ||
|
|
661ae11e18 | ||
|
|
18c1ac0fd7 | ||
|
|
2fb503f09f | ||
|
|
7e5eeb71da | ||
|
|
84f2da8b6d | ||
|
|
be51f8ae43 | ||
|
|
f82d985eab | ||
|
|
aab014650a | ||
|
|
113ac3e10e | ||
|
|
afb6aad26d | ||
|
|
33b60825d9 | ||
|
|
b5202a33ac | ||
|
|
c02a2ad622 | ||
|
|
e9f08915a4 | ||
|
|
4b95ccd0ba | ||
|
|
567a2f0720 | ||
|
|
61ff402256 | ||
|
|
9158bda992 |
@@ -13,7 +13,7 @@ coverage
|
||||
*.json5
|
||||
*.yaml
|
||||
*.yml
|
||||
|
||||
*.env
|
||||
|
||||
# exceptions
|
||||
!heroku.yml
|
||||
|
||||
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,6 +152,7 @@ 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**
|
||||
* 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/subredditReady)
|
||||
* 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
|
||||
|
||||
@@ -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 |
@@ -21,7 +21,7 @@ They are responsible for configuring the software at a high-level and managing a
|
||||
|
||||
# Overview
|
||||
|
||||
CM is composed of two applications that operate indepedently but are packaged together such that they act as one piece of software:
|
||||
CM is composed of two applications that operate independently but are packaged together such that they act as one piece of software:
|
||||
|
||||
* **Server** -- Responsible for **running the bot(s)** and providing an API to retrieve information on and interact with them EX start/stop bot, reload config, retrieve operational status, etc.
|
||||
* **Client** -- Responsible for serving the **web interface** and handling the bot oauth authentication flow between operators and subreddits/bots.
|
||||
|
||||
@@ -136,6 +136,8 @@ You will need have this information available:
|
||||
|
||||
See the [**example minimum configuration** below.](#minimum-config)
|
||||
|
||||
This configuration can also be **generated** by CM if you start CM with **no configuration defined** and visit the web interface.
|
||||
|
||||
# Bots
|
||||
|
||||
Configured using the `bots` top-level property. Bot configuration can override and specify many more options than are available at the operator-level. Many of these can also set the defaults for each subreddit the bot runs:
|
||||
|
||||
@@ -4,9 +4,7 @@ This getting started guide is for **Operators** -- that is, someone who wants to
|
||||
|
||||
* [Installation](#installation)
|
||||
* [Create a Reddit Client](#create-a-reddit-client)
|
||||
* [Create a Minimum Configuration](#create-a-minimum-configuration)
|
||||
* [Local Installation](#local-installation)
|
||||
* [Docker Installation](#docker-installation)
|
||||
* [Start ContextMod](#start-contextmod)
|
||||
* [Add a Bot to CM](#add-a-bot-to-cm)
|
||||
* [Access The Dashboard](#access-the-dashboard)
|
||||
* [What's Next?](#whats-next)
|
||||
@@ -19,29 +17,25 @@ Follow the [installation](/docs/operator/installation.md) documentation. It is r
|
||||
|
||||
[Create a reddit client](/docs/operator/README.md#provisioning-a-reddit-client)
|
||||
|
||||
# Create a Minimum Configuration
|
||||
# Start ContextMod
|
||||
|
||||
Using the information you received in the previous step [create a minimum file configuration](/docs/operator/configuration.md#minimum-configuration) save it as `config.yaml` somewhere.
|
||||
Start CM using the example command from your [installation](#installation) and visit http://localhost:8085
|
||||
|
||||
# Start ContextMod With Configuration
|
||||
The First Time Setup page will ask you to input:
|
||||
|
||||
## Local Installation
|
||||
* Client ID (from [Create a Reddit Client](#create-a-reddit-client))
|
||||
* Client Secret (from [Create a Reddit Client](#create-a-reddit-client))
|
||||
* Operator -- this is the username of your main Reddit account.
|
||||
|
||||
If you [installed CM locally](/docs/installation.md#locally) move your configuration file `config.yaml` to the root of the project directory (where `package.json`) is located.
|
||||
|
||||
From the root directory run this command to start CM
|
||||
|
||||
```
|
||||
node src/index.js run
|
||||
```
|
||||
|
||||
## Docker Installation
|
||||
|
||||
If you [installed CM using Docker](/docs/installation.md#docker-recommended) make note of the directory you saved your minimum configuration to and substitute its full path for `host/path/folder` in the docker command show in the [docker install directions](/docs/operator/installation.md#docker-recommended)
|
||||
**Write Config** and then restart CM. You have now created the [minimum configuration](/docs/operator/configuration.md#minimum-configuration) required to run CM.
|
||||
|
||||
# Add A Bot to CM
|
||||
|
||||
Once CM is up and running use the [CM OAuth Helper](/docs/operator/addingBot.md#cm-oauth-helper-recommended) to add authorize and add a Bot to your CM instance.
|
||||
You should automatically be directed to the [Bot Invite Helper](/docs/operator/addingBot.md#cm-oauth-helper-recommended) used to authorize and add a Bot to your CM instance.
|
||||
|
||||
Follow the directions here and **create an Authorization Invite** at the bottom of the page.
|
||||
|
||||
Next, login to Reddit with the account you will be using as the Bot and then visit the **Authorization Invite** link you created. Follow the steps there to finish adding the Bot to your CM instance.
|
||||
|
||||
# Access The Dashboard
|
||||
|
||||
@@ -57,4 +51,4 @@ As an operator you should familiarize yourself with how the [operator configurat
|
||||
|
||||
If you are also the moderator of the subreddit the bot will be running you should check out the [moderator getting started guide.](/docs/subreddit/gettingStarted.md#setup-wiki-page)
|
||||
|
||||
You might also be interested in these [quick tips for using the web interface](/docs/webInterface.md)
|
||||
You might also be interested in these [quick tips for using the web interface](/docs/webInterface.md). Additionally, on the dashboard click the **Help** button at the top of the page to get a guided tour of the dashboard.
|
||||
|
||||
@@ -8,15 +8,19 @@ 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):
|
||||
|
||||
* Bind the directory where your config file, logs, and database are located on your host machine into the container's default `DATA_DIR` by using `-v /host/path/folder:/config`
|
||||
* Note: **You must do this** or else your configuration will be lost next time your container is updated.
|
||||
* 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`
|
||||
@@ -33,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-reddit example configurations](/docs/subreddit/components/subredditReady) 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)
|
||||
@@ -1,12 +1,32 @@
|
||||
Actions that can submit text (Report, Comment, UserNote) will have their `content` values run through a [Mustache Template](https://mustache.github.io/). This means you can insert data generated by Rules into your text before the Action is performed.
|
||||
Actions that can submit text (Report, Comment, UserNote, Message, Ban, Submission) will have their `content` values run through a [Mustache Template](https://mustache.github.io/). This means you can insert data generated by Rules into your text before the Action is performed.
|
||||
|
||||
See here for a [cheatsheet](https://gist.github.com/FoxxMD/d365707cf99fdb526a504b8b833a5b78) and [here](https://www.tsmean.com/articles/mustache/the-ultimate-mustache-tutorial/) for a more thorough tutorial.
|
||||
|
||||
# Template Data
|
||||
|
||||
Some data can always be accessed at the top-level. Example
|
||||
|
||||
```
|
||||
This action was run from {{manager}} in Check {{check}}.
|
||||
|
||||
The bot intro post is {{botLink}}
|
||||
|
||||
Message the moderators of this subreddit using this [compose link]({{modmailLink}})
|
||||
```
|
||||
|
||||
|
||||
|
||||
| Name | Description | Example |
|
||||
|---------------|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `manager` | The name of the subreddit the bot is running in | mealtimevideos |
|
||||
| `check` | The name of the Check that was triggered | myCheck |
|
||||
| `botLink` | A link to the bot introduction | https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot |
|
||||
| `modmailLink` | A link that opens reddit's DM compose with the subject line as the Activity being processed | https://www.reddit.com/message/compose?to=/r/mealtimevideos&message=https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot |
|
||||
|
||||
|
||||
## Activity Data
|
||||
|
||||
Activity data can be accessed using the `item` variable. Example
|
||||
**Activity data can be accessed using the `item` variable.** Example
|
||||
|
||||
```
|
||||
This activity is a {{item.kind}} with {{item.votes}} votes, created {{item.age}} ago.
|
||||
@@ -19,14 +39,17 @@ Produces:
|
||||
|
||||
All Actions with `content` have access to this data:
|
||||
|
||||
| Name | Description | Example |
|
||||
|-------------|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
|
||||
| `kind` | The Activity type (submission or comment) | submission |
|
||||
| `author` | Name of the Author of the Activity being processed | FoxxMD |
|
||||
| `permalink` | URL to the Activity | https://reddit.com/r/mySuibreddit/comments/ab23f/my_post |
|
||||
| `votes` | Number of upvotes | 69 |
|
||||
| `age` | The age of the Activity in a [human friendly format](https://day.js.org/docs/en/durations/humanize) | 5 minutes |
|
||||
| `botLink` | A URL to CM's introduction thread | https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot |
|
||||
| Name | Description | Example |
|
||||
|--------------|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------|
|
||||
| `kind` | The Activity type (submission or comment) | submission |
|
||||
| `author` | Name of the Author of the Activity being processed | FoxxMD |
|
||||
| `permalink` | URL to the Activity | https://reddit.com/r/mySuibreddit/comments/ab23f/my_post |
|
||||
| `votes` | Number of upvotes | 69 |
|
||||
| `age` | The age of the Activity in a [human friendly format](https://day.js.org/docs/en/durations/humanize) | 5 minutes |
|
||||
| `subreddit` | The name of the subreddit the Activity is from | mealtimevideos |
|
||||
| `id` | The `Reddit Thing` ID for the Activity | t3_0tin1 |
|
||||
| `title` | As comments => the body of the comment. As Submission => title | Test post please ignore |
|
||||
| `shortTitle` | The same as `title` but truncated to 15 characters | test post pleas... |
|
||||
|
||||
### Submissions
|
||||
|
||||
@@ -89,7 +112,39 @@ Produces
|
||||
|
||||
> Submission was repeated 7 times
|
||||
|
||||
#### Quick Templating Tutorial
|
||||
## Action Data
|
||||
|
||||
### Summary
|
||||
|
||||
A summary of what actions have already been run **when the template is rendered** is available using the `actionSummary` variable. It is therefore important that the Action you want to produce the summary is run **after** any other Actions you want to get a summary for.
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
A summary of actions processed for this activity, so far:
|
||||
|
||||
{{actionSummary}}
|
||||
```
|
||||
|
||||
Would produce:
|
||||
> A summary of actions processed for this activity, so far:
|
||||
>
|
||||
> * approve - ✘ - Item is already approved??
|
||||
> * lock - ✓
|
||||
> * modnote - ✓ - (SOLID_CONTRIBUTOR) User is good
|
||||
|
||||
### Individual
|
||||
|
||||
Individual **Actions** can be accessed using the name of the action, **lower-cased, with all spaces/dashes/underscores.** Example:
|
||||
|
||||
```
|
||||
User was banned for {{actions.exampleban.duration}} for {{actions.exampleban.reason}}
|
||||
```
|
||||
Produces
|
||||
|
||||
> User was banned for 4 days for toxic behavior
|
||||
|
||||
# Quick Templating Tutorial
|
||||
|
||||
As a quick example for how you will most likely be using templating -- wrapping a variable in curly brackets, `{{variable}}`, will cause the variable value to be rendered instead of the brackets:
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -29,6 +30,7 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
|
||||
* [List of Actions](#list-of-actions)
|
||||
* [Approve](#approve)
|
||||
* [Ban](#ban)
|
||||
* [Submission](#submission)
|
||||
* [Comment](#comment)
|
||||
* [Contributor (Add/Remove)](#contributor)
|
||||
* [Dispatch/Delay](#dispatch)
|
||||
@@ -376,6 +378,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.
|
||||
@@ -494,11 +502,30 @@ actions:
|
||||
|
||||
### Comment
|
||||
|
||||
Reply to the Activity being processed with a comment. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FCommentActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
|
||||
Reply to an Activity with a comment. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FCommentActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
|
||||
|
||||
* If the Activity is a Submission the comment is a top-level reply
|
||||
* If the Activity is a Comment the comment is a child reply
|
||||
|
||||
#### Templating
|
||||
|
||||
`content` can be [templated](#templating) and use [URL Tokens](#url-tokens)
|
||||
|
||||
#### Targets
|
||||
|
||||
Optionally, specify the Activity CM should reply to. **When not specified CM replies to the Activity being processed using `self`**
|
||||
|
||||
Valid values: `self`, `parent`, or a Reddit permalink.
|
||||
|
||||
`self` and `parent` are special targets that are relative to the Activity being processed:
|
||||
|
||||
* When the Activity being processed is a **Submission** => `parent` logs a warning and does nothing
|
||||
* When the Activity being processed is a **Comment**
|
||||
* `self` => reply to Comment
|
||||
* `parent` => make a top-level Comment in the **Submission** the Comment belong to
|
||||
|
||||
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
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: comment
|
||||
@@ -506,7 +533,71 @@ actions:
|
||||
distinguish: boolean # distinguish as a mod
|
||||
sticky: boolean # sticky 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)
|
||||
|
||||
The Submission type, Link or Self-Post, is determined based on the presence of `url` in the action's configuration.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: submission
|
||||
title: string # required, the title of the submission. can be templated.
|
||||
content: string # the body of the submission. can be templated
|
||||
url: string # if specified the submission will be a Link Submission. can be templated
|
||||
distinguish: boolean # distinguish as a mod
|
||||
sticky: boolean # sticky comment
|
||||
lock: boolean # lock the comment after creation
|
||||
nsfw: boolean # mark submission as NSFW
|
||||
spoiler: boolean # mark submission as a spoiler
|
||||
flairId: string # flair template id for submission
|
||||
flairText: string # flair text for submission
|
||||
targets: string # 'self' or a subreddit name IE mealtimevideos
|
||||
```
|
||||
|
||||
#### Templating
|
||||
|
||||
`content`,`url`, and `title` can be [templated](#templating) and use [URL Tokens](#url-tokens)
|
||||
|
||||
TIP: To create a Link Submission pointing to the Activity currently being processed use
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: submission
|
||||
url: {{item.permalink}}
|
||||
# ...
|
||||
```
|
||||
|
||||
#### Targets
|
||||
|
||||
Optionally, specify the Subreddit the Submission should be made in. **When not specified CM uses `self`**
|
||||
|
||||
Valid values: `self` or Subreddit Name
|
||||
|
||||
* `self` => (**Default**) Create Submission in the same Subreddit of the Activity being processed
|
||||
* Subreddit Name => Create Submission in given subreddit IE `mealtimevideos`
|
||||
* Your bot must be able to access and be able to post in the given subreddit
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: comment
|
||||
targets: mealtimevideos
|
||||
```
|
||||
|
||||
To post to multiple subreddits use a list:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: comment
|
||||
targets:
|
||||
- self
|
||||
- mealtimevideos
|
||||
- anotherSubreddit
|
||||
```
|
||||
|
||||
### Contributor
|
||||
@@ -608,15 +699,16 @@ Some other things to note:
|
||||
* If the `to` property is not specified then the message is sent to the Author of the Activity being processed
|
||||
* `to` may be a **User** (u/aUser) or a **Subreddit** (r/aSubreddit)
|
||||
* `to` **cannot** be a Subreddit when `asSubreddit: true` -- IE cannot send subreddit-to-subreddit messages
|
||||
* `content` can be [templated](#templating) and use [URL Tokens](#url-tokens)
|
||||
* TIP: `to` can be templated -- to send a message to the subreddit the Activity being processed is in use `'r/{{item.subreddit}}'`
|
||||
* `content` and `title` can be [templated](#templating) and use [URL Tokens](#url-tokens)
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: message
|
||||
asSubreddit: true
|
||||
content: 'A message sent as the subreddit'
|
||||
title: 'Title of the message'
|
||||
to: 'u/aUser' # do not specify 'to' in order default to sending to Author of Activity being processed
|
||||
content: 'A message sent as the subreddit' # can be templated
|
||||
title: 'Title of the message' # can be templated
|
||||
to: 'u/aUser' # do not specify 'to' in order default to sending to Author of Activity being processed. Can also be templated
|
||||
```
|
||||
|
||||
### Remove
|
||||
|
||||
@@ -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
|
||||
|
||||
165
docs/subreddit/components/mhs/README.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
# 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
|
||||
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
|
||||
confidence: '>= 95'
|
||||
flagged: false
|
||||
actions:
|
||||
- kind: approve
|
||||
```
|
||||
@@ -43,7 +43,7 @@
|
||||
// remove this after confirming behavior is acceptable
|
||||
{
|
||||
"kind": "report",
|
||||
"content": "Remove=> {{rules.newtube.totalCount}} activities in freekarma subs"
|
||||
"content": "Remove=> {{rules.freekarma.totalCount}} activities in freekarma subs"
|
||||
},
|
||||
//
|
||||
//
|
||||
|
||||
@@ -25,7 +25,7 @@ runs:
|
||||
actions:
|
||||
- kind: report
|
||||
enable: true
|
||||
content: 'Remove=> {{rules.newtube.totalCount}} activities in freekarma subs'
|
||||
content: 'Remove=> {{rules.freekarma.totalCount}} activities in freekarma subs'
|
||||
- kind: remove
|
||||
enable: true
|
||||
- kind: comment
|
||||
|
||||
42
package-lock.json
generated
@@ -11,6 +11,7 @@
|
||||
"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",
|
||||
@@ -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",
|
||||
@@ -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",
|
||||
@@ -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,6 +31,7 @@
|
||||
"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",
|
||||
|
||||
@@ -3,7 +3,7 @@ import LockAction, {LockActionJson} from "./LockAction";
|
||||
import {RemoveAction, RemoveActionJson} from "./RemoveAction";
|
||||
import {ReportAction, ReportActionJson} from "./ReportAction";
|
||||
import {FlairAction, FlairActionJson} from "./SubmissionAction/FlairAction";
|
||||
import Action, {ActionJson, StructuredActionJson} from "./index";
|
||||
import Action, {ActionJson, ActionRuntimeOptions, StructuredActionJson} from "./index";
|
||||
import {Logger} from "winston";
|
||||
import {UserNoteAction, UserNoteActionJson} from "./UserNoteAction";
|
||||
import ApproveAction, {ApproveActionConfig} from "./ApproveAction";
|
||||
@@ -18,38 +18,41 @@ import {CancelDispatchAction, CancelDispatchActionJson} from "./CancelDispatchAc
|
||||
import ContributorAction, {ContributorActionJson} from "./ContributorAction";
|
||||
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {ModNoteAction, ModNoteActionJson} from "./ModNoteAction";
|
||||
import {SubmissionAction, SubmissionActionJson} from "./SubmissionAction";
|
||||
|
||||
export function actionFactory
|
||||
(config: StructuredActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap, emitter: EventEmitter): Action {
|
||||
(config: StructuredActionJson, runtimeOptions: ActionRuntimeOptions): Action {
|
||||
switch (config.kind) {
|
||||
case 'comment':
|
||||
return new CommentAction({...config as StructuredFilter<CommentActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new CommentAction({...config as StructuredFilter<CommentActionJson>, ...runtimeOptions});
|
||||
case 'submission':
|
||||
return new SubmissionAction({...config as StructuredFilter<SubmissionActionJson>, ...runtimeOptions});
|
||||
case 'lock':
|
||||
return new LockAction({...config as StructuredFilter<LockActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new LockAction({...config as StructuredFilter<LockActionJson>, ...runtimeOptions});
|
||||
case 'remove':
|
||||
return new RemoveAction({...config as StructuredFilter<RemoveActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new RemoveAction({...config as StructuredFilter<RemoveActionJson>, ...runtimeOptions});
|
||||
case 'report':
|
||||
return new ReportAction({...config as StructuredFilter<ReportActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new ReportAction({...config as StructuredFilter<ReportActionJson>, ...runtimeOptions});
|
||||
case 'flair':
|
||||
return new FlairAction({...config as StructuredFilter<FlairActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new FlairAction({...config as StructuredFilter<FlairActionJson>, ...runtimeOptions});
|
||||
case 'userflair':
|
||||
return new UserFlairAction({...config as StructuredFilter<UserFlairActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new UserFlairAction({...config as StructuredFilter<UserFlairActionJson>, ...runtimeOptions});
|
||||
case 'approve':
|
||||
return new ApproveAction({...config as StructuredFilter<ApproveActionConfig>, logger, subredditName, resources, client, emitter});
|
||||
return new ApproveAction({...config as StructuredFilter<ApproveActionConfig>, ...runtimeOptions});
|
||||
case 'usernote':
|
||||
return new UserNoteAction({...config as StructuredFilter<UserNoteActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new UserNoteAction({...config as StructuredFilter<UserNoteActionJson>, ...runtimeOptions});
|
||||
case 'ban':
|
||||
return new BanAction({...config as StructuredFilter<BanActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new BanAction({...config as StructuredFilter<BanActionJson>, ...runtimeOptions});
|
||||
case 'message':
|
||||
return new MessageAction({...config as StructuredFilter<MessageActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new MessageAction({...config as StructuredFilter<MessageActionJson>, ...runtimeOptions});
|
||||
case 'dispatch':
|
||||
return new DispatchAction({...config as StructuredFilter<DispatchActionJson>, logger, subredditName, resources, client, emitter});
|
||||
return new DispatchAction({...config as StructuredFilter<DispatchActionJson>, ...runtimeOptions});
|
||||
case 'cancelDispatch':
|
||||
return new CancelDispatchAction({...config as StructuredFilter<CancelDispatchActionJson>, logger, subredditName, resources, client, emitter})
|
||||
return new CancelDispatchAction({...config as StructuredFilter<CancelDispatchActionJson>, ...runtimeOptions})
|
||||
case 'contributor':
|
||||
return new ContributorAction({...config as StructuredFilter<ContributorActionJson>, logger, subredditName, resources, client, emitter})
|
||||
return new ContributorAction({...config as StructuredFilter<ContributorActionJson>, ...runtimeOptions})
|
||||
case 'modnote':
|
||||
return new ModNoteAction({...config as StructuredFilter<ModNoteActionJson>, logger, subredditName, resources, client, emitter})
|
||||
return new ModNoteAction({...config as StructuredFilter<ModNoteActionJson>, ...runtimeOptions})
|
||||
default:
|
||||
throw new Error('rule "kind" was not recognized.');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTarget, ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {asComment, asSubmission} from "../util";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class ApproveAction extends Action {
|
||||
|
||||
@@ -26,7 +27,7 @@ export class ApproveAction extends Action {
|
||||
this.targets = targets;
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const touchedEntities = [];
|
||||
|
||||
|
||||
@@ -7,10 +7,18 @@ import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {truncateStringToLength} from "../util";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
const truncate = truncateStringToLength(100);
|
||||
const truncateLongMessage = truncateStringToLength(200);
|
||||
|
||||
const truncateIfNotUndefined = (val: string | undefined) => {
|
||||
if(val === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return truncate(val);
|
||||
}
|
||||
|
||||
export class BanAction extends Action {
|
||||
|
||||
message?: string;
|
||||
@@ -39,13 +47,13 @@ export class BanAction extends Action {
|
||||
return 'ban';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const renderedBody = this.message === undefined ? undefined : await this.resources.renderContent(this.message, item, ruleResults);
|
||||
const renderedContent = renderedBody === undefined ? undefined : `${renderedBody}${await this.resources.generateFooter(item, this.footer)}`;
|
||||
const renderedBody = await this.renderContent(this.message, item, ruleResults, actionResults);
|
||||
const renderedContent = renderedBody === undefined ? undefined : `${renderedBody}${await this.resources.renderFooter(item, this.footer)}`;
|
||||
|
||||
const renderedReason = this.reason === undefined ? undefined : truncate(await this.resources.renderContent(this.reason, item, ruleResults));
|
||||
const renderedNote = this.note === undefined ? undefined : truncate(await this.resources.renderContent(this.note, item, ruleResults));
|
||||
const renderedReason = truncateIfNotUndefined(await this.renderContent(this.reason, item, ruleResults, actionResults) as string);
|
||||
const renderedNote = truncateIfNotUndefined(await this.renderContent(this.note, item, ruleResults, actionResults) as string);
|
||||
|
||||
const touchedEntities = [];
|
||||
let banPieces = [];
|
||||
@@ -72,7 +80,13 @@ export class BanAction extends Action {
|
||||
dryRun,
|
||||
success: true,
|
||||
result: `Banned ${item.author.name} ${durText}${renderedReason !== undefined ? ` (${renderedReason})` : ''}`,
|
||||
touchedEntities
|
||||
touchedEntities,
|
||||
data: {
|
||||
message: renderedContent === undefined ? undefined : renderedContent,
|
||||
reason: renderedReason,
|
||||
note: renderedNote,
|
||||
duration: durText
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {isSubmission, parseDurationValToDuration} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTarget, ActionTypes, InclusiveActionTarget} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class CancelDispatchAction extends Action {
|
||||
identifiers?: (string | null)[];
|
||||
@@ -35,7 +36,7 @@ export class CancelDispatchAction extends Action {
|
||||
this.targets = !Array.isArray(target) ? [target] : target;
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
// see note in DispatchAction about missing runtimeDryrun
|
||||
const dryRun = this.dryRun;
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import Action, {ActionJson, ActionOptions} from "./index";
|
||||
import {Comment} from "snoowrap";
|
||||
import {Comment, VoteableContent} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {ActionProcessResult, Footer, RequiredRichContent, RichContent, RuleResult} from "../Common/interfaces";
|
||||
import {truncateStringToLength} from "../util";
|
||||
import {asComment, asSubmission, parseRedditThingsFromLink, truncateStringToLength} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infrastructure/Atomic";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class CommentAction extends Action {
|
||||
content: string;
|
||||
@@ -14,6 +17,7 @@ export class CommentAction extends Action {
|
||||
sticky: boolean = false;
|
||||
distinguish: boolean = false;
|
||||
footer?: false | string;
|
||||
targets: ArbitraryActionTarget[]
|
||||
|
||||
constructor(options: CommentActionOptions) {
|
||||
super(options);
|
||||
@@ -23,71 +27,139 @@ export class CommentAction extends Action {
|
||||
sticky = false,
|
||||
distinguish = false,
|
||||
footer,
|
||||
targets = ['self']
|
||||
} = options;
|
||||
this.footer = footer;
|
||||
this.content = content;
|
||||
this.lock = lock;
|
||||
this.sticky = sticky;
|
||||
this.distinguish = distinguish;
|
||||
if (!Array.isArray(targets)) {
|
||||
this.targets = [targets];
|
||||
} else {
|
||||
this.targets = targets;
|
||||
}
|
||||
}
|
||||
|
||||
getKind(): ActionTypes {
|
||||
return 'comment';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const content = await this.resources.getContent(this.content, item.subreddit);
|
||||
const body = await renderContent(content, item, ruleResults, this.resources.userNotes);
|
||||
const body = await this.renderContent(this.content, item, ruleResults, actionResults) as string;
|
||||
|
||||
const footer = await this.resources.generateFooter(item, this.footer);
|
||||
const footer = await this.resources.renderFooter(item, this.footer);
|
||||
|
||||
const renderedContent = `${body}${footer}`;
|
||||
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
|
||||
|
||||
if(item.archived) {
|
||||
this.logger.warn('Cannot comment because Item is archived');
|
||||
return {
|
||||
dryRun,
|
||||
success: false,
|
||||
result: 'Cannot comment because Item is archived'
|
||||
};
|
||||
}
|
||||
let allErrors = true;
|
||||
const targetResults: string[] = [];
|
||||
const touchedEntities = [];
|
||||
let modifiers = [];
|
||||
let reply: Comment;
|
||||
if(!dryRun) {
|
||||
// @ts-ignore
|
||||
reply = await item.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) {
|
||||
modifiers.push('Locked');
|
||||
|
||||
for (const target of this.targets) {
|
||||
|
||||
let targetItem = item;
|
||||
let targetIdentifier = target;
|
||||
|
||||
if (target === 'parent') {
|
||||
if (asSubmission(item)) {
|
||||
const noParent = `[Parent] Submission ${item.name} does not have a parent`;
|
||||
this.logger.warn(noParent);
|
||||
targetResults.push(noParent);
|
||||
continue;
|
||||
}
|
||||
targetItem = await this.resources.getActivity(this.client.getSubmission(item.link_id));
|
||||
} else if (target !== 'self') {
|
||||
const redditThings = parseRedditThingsFromLink(target);
|
||||
let id = '';
|
||||
|
||||
try {
|
||||
if (redditThings.comment !== undefined) {
|
||||
id = redditThings.comment.id;
|
||||
targetIdentifier = `Permalink Comment ${id}`
|
||||
// @ts-ignore
|
||||
await this.resources.getActivity(this.client.getSubmission(redditThings.submission.id));
|
||||
targetItem = await this.resources.getActivity(this.client.getComment(redditThings.comment.id));
|
||||
} else if (redditThings.submission !== undefined) {
|
||||
id = redditThings.submission.id;
|
||||
targetIdentifier = `Permalink Submission ${id}`
|
||||
targetItem = await this.resources.getActivity(this.client.getSubmission(redditThings.submission.id));
|
||||
} else {
|
||||
targetResults.push(`[Permalink] Could not parse ${target} as a reddit permalink`);
|
||||
continue;
|
||||
}
|
||||
} catch (err: any) {
|
||||
targetResults.push(`[${targetIdentifier}] error occurred while fetching activity: ${err.message}`);
|
||||
this.logger.warn(new CMError(`[${targetIdentifier}] error occurred while fetching activity`, {cause: err}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetItem.archived) {
|
||||
const archived = `[${targetIdentifier}] Cannot comment because Item is archived`;
|
||||
this.logger.warn(archived);
|
||||
targetResults.push(archived);
|
||||
continue;
|
||||
}
|
||||
|
||||
let modifiers = [];
|
||||
let reply: Comment;
|
||||
if (!dryRun) {
|
||||
// snoopwrap typing issue, thinks comments can't be locked
|
||||
// @ts-ignore
|
||||
await reply.lock();
|
||||
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.distinguish && !dryRun) {
|
||||
modifiers.push('Distinguished');
|
||||
if(this.sticky) {
|
||||
modifiers.push('Stickied');
|
||||
|
||||
if (this.lock && targetItem.can_mod_post) {
|
||||
if (!targetItem.can_mod_post) {
|
||||
this.logger.warn(`[${targetIdentifier}] Cannot lock because bot is not a moderator`);
|
||||
} else {
|
||||
modifiers.push('Locked');
|
||||
if (!dryRun) {
|
||||
// snoopwrap typing issue, thinks comments can't be locked
|
||||
// @ts-ignore
|
||||
await reply.lock();
|
||||
}
|
||||
}
|
||||
}
|
||||
if(!dryRun) {
|
||||
// @ts-ignore
|
||||
await reply.distinguish({sticky: this.sticky});
|
||||
|
||||
if (this.distinguish) {
|
||||
if (!targetItem.can_mod_post) {
|
||||
this.logger.warn(`[${targetIdentifier}] Cannot lock Distinguish/Sticky because bot is not a moderator`);
|
||||
} else {
|
||||
modifiers.push('Distinguished');
|
||||
if (this.sticky) {
|
||||
modifiers.push('Stickied');
|
||||
}
|
||||
if (!dryRun) {
|
||||
// @ts-ignore
|
||||
await reply.distinguish({sticky: this.sticky});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modifierStr = modifiers.length === 0 ? '' : ` == ${modifiers.join(' | ')} == =>`;
|
||||
// @ts-ignore
|
||||
targetResults.push(`${targetIdentifier}${modifierStr} created Comment ${dryRun ? 'DRYRUN' : (reply as SnoowrapActivity).name}`)
|
||||
allErrors = false;
|
||||
}
|
||||
|
||||
const modifierStr = modifiers.length === 0 ? '' : `[${modifiers.join(' | ')}]`;
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
success: true,
|
||||
result: `${modifierStr}${truncateStringToLength(100)(body)}`,
|
||||
success: !allErrors,
|
||||
result: `${targetResults.join('\n')}${truncateStringToLength(100)(body)}`,
|
||||
touchedEntities,
|
||||
data: {
|
||||
body,
|
||||
bodyShort: truncateStringToLength(100)(body),
|
||||
comments: targetResults,
|
||||
commentsFormatted: targetResults.map(x => `* ${x}`).join('\n')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -97,7 +169,8 @@ export class CommentAction extends Action {
|
||||
lock: this.lock,
|
||||
sticky: this.sticky,
|
||||
distinguish: this.distinguish,
|
||||
footer: this.footer
|
||||
footer: this.footer,
|
||||
targets: this.targets,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +188,21 @@ export interface CommentActionConfig extends RequiredRichContent, Footer {
|
||||
* Distinguish the comment after creation?
|
||||
* */
|
||||
distinguish?: boolean,
|
||||
|
||||
/**
|
||||
* Specify where this comment should be made
|
||||
*
|
||||
* Valid values: 'self' | 'parent' | [reddit permalink]
|
||||
*
|
||||
* 'self' and 'parent' are special targets that are relative to the Activity being processed:
|
||||
* * When Activity is Submission => 'parent' does nothing
|
||||
* * When Activity is Comment
|
||||
* * 'self' => reply to Activity
|
||||
* * 'parent' => make a top-level comment in the Submission the Comment is in
|
||||
*
|
||||
* 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[]
|
||||
}
|
||||
|
||||
export interface CommentActionOptions extends CommentActionConfig, ActionOptions {
|
||||
@@ -124,5 +212,5 @@ export interface CommentActionOptions extends CommentActionConfig, ActionOptions
|
||||
* Reply to the Activity. For a submission the reply will be a top-level comment.
|
||||
* */
|
||||
export interface CommentActionJson extends CommentActionConfig, ActionJson {
|
||||
kind: 'comment'
|
||||
kind: 'comment'
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import Comment from "snoowrap/dist/objects/Comment";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTarget, ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class ContributorAction extends Action {
|
||||
|
||||
@@ -25,7 +26,7 @@ export class ContributorAction extends Action {
|
||||
this.actionType = action;
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
|
||||
const contributors = await this.resources.getSubredditContributors();
|
||||
|
||||
@@ -8,6 +8,7 @@ import {activityDispatchConfigToDispatch, isSubmission, parseDurationValToDurati
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTarget, ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class DispatchAction extends Action {
|
||||
dispatchData: ActivityDispatchConfig;
|
||||
@@ -39,7 +40,7 @@ export class DispatchAction extends Action {
|
||||
this.targets = !Array.isArray(target) ? [target] : target;
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
// ignore runtimeDryrun here because "real run" isn't causing any reddit api calls to happen
|
||||
// -- basically if bot is in dryrun this should still run since we want the "full effect" of the bot
|
||||
// BUT if the action explicitly sets 'dryRun: true' then do not dispatch as they probably don't want to it actually going (intention?)
|
||||
|
||||
@@ -5,13 +5,14 @@ import {ActionProcessResult, RuleResult} from "../Common/interfaces";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class LockAction extends Action {
|
||||
getKind(): ActionTypes {
|
||||
return 'lock';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const touchedEntities = [];
|
||||
//snoowrap typing issue, thinks comments can't be locked
|
||||
|
||||
@@ -16,6 +16,7 @@ import {ErrorWithCause} from "pony-cause";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class MessageAction extends Action {
|
||||
content: string;
|
||||
@@ -48,28 +49,30 @@ export class MessageAction extends Action {
|
||||
return 'message';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
|
||||
const body = await this.resources.renderContent(this.content, item, ruleResults);
|
||||
const subject = this.title === undefined ? `Concerning your ${isSubmission(item) ? 'Submission' : 'Comment'}` : await this.resources.renderContent(this.title, item, ruleResults);
|
||||
const body = await this.renderContent(this.content, item, ruleResults, actionResults);
|
||||
const titleTemplate = this.title ?? `Concerning your ${isSubmission(item) ? 'Submission' : 'Comment'}`;
|
||||
const subject = await this.renderContent(titleTemplate, item, ruleResults, actionResults) as string;
|
||||
|
||||
const footer = await this.resources.generateFooter(item, this.footer);
|
||||
const footer = await this.resources.renderFooter(item, this.footer);
|
||||
|
||||
const renderedContent = `${body}${footer}`;
|
||||
|
||||
let recipient = item.author.name;
|
||||
if(this.to !== undefined) {
|
||||
const renderedTo = await this.renderContent(this.to, item, ruleResults, actionResults) as string;
|
||||
// parse to value
|
||||
try {
|
||||
const entityData = parseRedditEntity(this.to, 'user');
|
||||
const entityData = parseRedditEntity(renderedTo, 'user');
|
||||
if(entityData.type === 'user') {
|
||||
recipient = entityData.name;
|
||||
} else {
|
||||
recipient = `/r/${entityData.name}`;
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new ErrorWithCause(`'to' field for message was not in a valid format. See ${REDDIT_ENTITY_REGEX_URL} for valid examples`, {cause: err});
|
||||
throw new ErrorWithCause(`'to' field for message was not in a valid format, given value after templating: ${renderedTo} -- See ${REDDIT_ENTITY_REGEX_URL} for valid examples`, {cause: err});
|
||||
}
|
||||
if(recipient.includes('/r/') && this.asSubreddit) {
|
||||
throw new SimpleError(`Cannot send a message as a subreddit to another subreddit. Requested recipient: ${recipient}`);
|
||||
@@ -123,7 +126,7 @@ export interface MessageActionConfig extends RequiredRichContent, Footer {
|
||||
asSubreddit: boolean
|
||||
|
||||
/**
|
||||
* Entity to send message to.
|
||||
* Entity to send message to. It can be templated.
|
||||
*
|
||||
* If not present Message be will sent to the Author of the Activity being checked.
|
||||
*
|
||||
@@ -135,8 +138,9 @@ export interface MessageActionConfig extends RequiredRichContent, Footer {
|
||||
*
|
||||
* **Note:** Reddit does not support sending a message AS a subreddit TO another subreddit
|
||||
*
|
||||
* @pattern ^\s*(\/[ru]\/|[ru]\/)*(\w+)*\s*$
|
||||
* @examples ["aUserName","u/aUserName","r/aSubreddit"]
|
||||
* **Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`
|
||||
*
|
||||
* @examples ["aUserName","u/aUserName","r/aSubreddit", "r/{{item.subreddit}}"]
|
||||
* */
|
||||
to?: string
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
|
||||
export class ModNoteAction extends Action {
|
||||
@@ -39,13 +40,12 @@ export class ModNoteAction extends Action {
|
||||
}
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
|
||||
const modLabel = this.type !== undefined ? toModNoteLabel(this.type) : undefined;
|
||||
|
||||
const content = await this.resources.getContent(this.content, item.subreddit);
|
||||
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ import {isSubmission, truncateStringToLength} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
const truncate = truncateStringToLength(100);
|
||||
export class RemoveAction extends Action {
|
||||
@@ -31,7 +32,7 @@ export class RemoveAction extends Action {
|
||||
this.reasonId = reasonId;
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const touchedEntities = [];
|
||||
let removeSummary = [];
|
||||
@@ -44,7 +45,7 @@ export class RemoveAction extends Action {
|
||||
removeSummary.push('Marked as SPAM');
|
||||
this.logger.verbose('Marking as spam on removal');
|
||||
}
|
||||
const renderedNote = this.note === undefined ? undefined : await this.resources.renderContent(this.note, item, ruleResults);
|
||||
const renderedNote = await this.renderContent(this.note, item, ruleResults, actionResults);
|
||||
let foundReasonId: string | undefined;
|
||||
let foundReason: string | undefined;
|
||||
|
||||
@@ -99,7 +100,8 @@ export class RemoveAction extends Action {
|
||||
return {
|
||||
dryRun,
|
||||
success: true,
|
||||
touchedEntities
|
||||
touchedEntities,
|
||||
result: removeSummary.join(' | ')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {ActionProcessResult, RichContent, RuleResult} from "../Common/interfaces
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
// https://www.reddit.com/dev/api/oauth#POST_api_report
|
||||
// denotes 100 characters maximum
|
||||
@@ -25,10 +26,9 @@ export class ReportAction extends Action {
|
||||
return 'report';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const content = await this.resources.getContent(this.content, item.subreddit);
|
||||
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
|
||||
const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string);
|
||||
this.logger.verbose(`Contents:\r\n${renderedContent}`);
|
||||
const truncatedContent = reportTrunc(renderedContent);
|
||||
const touchedEntities = [];
|
||||
|
||||
329
src/Action/SubmissionAction.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import Action, {ActionJson, ActionOptions} from "./index";
|
||||
import {Comment, SubmitLinkOptions, SubmitSelfPostOptions, VoteableContent} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {ActionProcessResult, Footer, RequiredRichContent, RichContent, RuleResult} from "../Common/interfaces";
|
||||
import {asComment, asSubmission, parseRedditEntity, parseRedditThingsFromLink, sleep, truncateStringToLength} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infrastructure/Atomic";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
||||
import Subreddit from "snoowrap/dist/objects/Subreddit";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class SubmissionAction extends Action {
|
||||
content?: string;
|
||||
lock: boolean = false;
|
||||
sticky: boolean = false;
|
||||
distinguish: boolean = false;
|
||||
spoiler: boolean = false;
|
||||
nsfw: boolean = false;
|
||||
flairId?: string
|
||||
flairText?: string
|
||||
url?: string
|
||||
title: string
|
||||
footer?: false | string;
|
||||
targets: ('self' | string)[]
|
||||
|
||||
constructor(options: SubmissionActionOptions) {
|
||||
super(options);
|
||||
const {
|
||||
content,
|
||||
lock = false,
|
||||
sticky = false,
|
||||
spoiler = false,
|
||||
distinguish = false,
|
||||
nsfw = false,
|
||||
flairText,
|
||||
flairId,
|
||||
footer,
|
||||
url,
|
||||
title,
|
||||
targets = ['self']
|
||||
} = options;
|
||||
this.footer = footer;
|
||||
this.content = content;
|
||||
this.lock = lock;
|
||||
this.sticky = sticky;
|
||||
if(this.sticky) {
|
||||
this.distinguish = sticky;
|
||||
} else {
|
||||
this.distinguish = distinguish;
|
||||
}
|
||||
this.spoiler = spoiler;
|
||||
this.nsfw = nsfw;
|
||||
this.flairText = flairText;
|
||||
this.flairId = flairId;
|
||||
this.url = url;
|
||||
this.title = title;
|
||||
if (!Array.isArray(targets)) {
|
||||
this.targets = [targets];
|
||||
} else {
|
||||
this.targets = targets;
|
||||
}
|
||||
}
|
||||
|
||||
getKind(): ActionTypes {
|
||||
return 'submission';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
|
||||
const title = await this.renderContent(this.title, item, ruleResults, actionResults) as string;
|
||||
this.logger.verbose(`Title: ${title}`);
|
||||
|
||||
const url = await this.renderContent(this.url, item, ruleResults, actionResults);
|
||||
|
||||
this.logger.verbose(`URL: ${url !== undefined ? url : '[No URL]'}`);
|
||||
|
||||
const body = await this.renderContent(this.content, item, ruleResults, actionResults);
|
||||
|
||||
let renderedContent: string | undefined = undefined;
|
||||
if(body !== undefined) {
|
||||
const footer = await this.resources.renderFooter(item, this.footer);
|
||||
renderedContent = `${body}${footer}`;
|
||||
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
|
||||
} else {
|
||||
this.logger.verbose(`Contents: [No Body]`);
|
||||
}
|
||||
|
||||
|
||||
let allErrors = true;
|
||||
const targetResults: string[] = [];
|
||||
const touchedEntities = [];
|
||||
|
||||
let submittedOnce = false;
|
||||
|
||||
for (const targetVal of this.targets) {
|
||||
|
||||
//
|
||||
if(submittedOnce) {
|
||||
// delay submissions by 3 seconds (on previous successful call)
|
||||
// to try to spread out load
|
||||
await sleep(3000);
|
||||
}
|
||||
|
||||
let target: Subreddit = item.subreddit;
|
||||
let targetIdentifier = targetVal;
|
||||
|
||||
if (targetVal !== 'self') {
|
||||
const subredditVal = parseRedditEntity(targetVal);
|
||||
|
||||
try {
|
||||
target = await this.resources.getSubreddit(subredditVal.name);
|
||||
targetIdentifier = `[Subreddit ${target.display_name}]`;
|
||||
} catch (err: any) {
|
||||
targetResults.push(`[${targetIdentifier}] error occurred while fetching subreddit: ${err.message}`);
|
||||
if(!err.logged) {
|
||||
this.logger.warn(new CMError(`[${targetIdentifier}] error occurred while fetching subreddit`, {cause: err}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO check if we can post in subreddit
|
||||
|
||||
let modifiers = [];
|
||||
let post: Submission | undefined;
|
||||
if (!dryRun) {
|
||||
let opts: SubmitLinkOptions | SubmitSelfPostOptions;
|
||||
let type: 'self' | 'link';
|
||||
const genericOpts = {
|
||||
title,
|
||||
subredditName: target.display_name,
|
||||
nsfw: this.nsfw,
|
||||
spoiler: this.spoiler,
|
||||
flairId: this.flairId,
|
||||
flairText: this.flairText,
|
||||
};
|
||||
if(url !== undefined) {
|
||||
type = 'link';
|
||||
opts = {
|
||||
...genericOpts,
|
||||
url,
|
||||
};
|
||||
if(renderedContent !== undefined) {
|
||||
// @ts-ignore
|
||||
linkOpts.text = renderedContent;
|
||||
}
|
||||
} else {
|
||||
type = 'self';
|
||||
opts = {
|
||||
...genericOpts,
|
||||
text: renderedContent,
|
||||
}
|
||||
}
|
||||
// @ts-ignore
|
||||
post = await this.tryPost(type, target, opts);
|
||||
await this.resources.setRecentSelf(post as Submission);
|
||||
if(post !== undefined) {
|
||||
touchedEntities.push(post);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.lock) {
|
||||
if (post !== undefined && !post.can_mod_post) {
|
||||
this.logger.warn(`[${targetIdentifier}] Cannot lock because bot is not a moderator`);
|
||||
} else {
|
||||
modifiers.push('Locked');
|
||||
if (!dryRun && post !== undefined) {
|
||||
// snoopwrap typing issue, thinks comments can't be locked
|
||||
// @ts-ignore
|
||||
await post.lock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.distinguish) {
|
||||
if (post !== undefined && !post.can_mod_post) {
|
||||
this.logger.warn(`[${targetIdentifier}] Cannot Distinguish/Sticky because bot is not a moderator`);
|
||||
} else {
|
||||
modifiers.push('Distinguished');
|
||||
if (this.sticky) {
|
||||
modifiers.push('Stickied');
|
||||
}
|
||||
if (!dryRun && post !== undefined) {
|
||||
// @ts-ignore
|
||||
await post.distinguish({sticky: this.sticky});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const modifierStr = modifiers.length === 0 ? '' : ` == ${modifiers.join(' | ')} == =>`;
|
||||
const targetSummary = `${targetIdentifier} ${modifierStr} created Submission ${dryRun ? 'DRYRUN' : (post as SnoowrapActivity).name}`;
|
||||
// @ts-ignore
|
||||
targetResults.push(targetSummary)
|
||||
this.logger.verbose(targetSummary);
|
||||
allErrors = false;
|
||||
}
|
||||
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
success: !allErrors,
|
||||
result: `${targetResults.join('\n')}${this.url !== undefined ? `\nURL: ${this.url}` : ''}${body !== undefined ? truncateStringToLength(100)(body) : ''}`,
|
||||
touchedEntities,
|
||||
data: {
|
||||
body,
|
||||
bodyShort: body !== undefined ? truncateStringToLength(100)(body) : '',
|
||||
submissions: targetResults.map(x => `* ${x}`).join('\n')
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
protected async tryPost(type: 'self' | 'link', target: Subreddit, data: SubmitLinkOptions | SubmitSelfPostOptions, maxAttempts = 2): Promise<Submission> {
|
||||
let post: Submission | undefined;
|
||||
let error: any;
|
||||
for (let i = 0; i <= maxAttempts; i++) {
|
||||
try {
|
||||
if (type === 'self') {
|
||||
// @ts-ignore
|
||||
post = await target.submitSelfpost(data as SubmitSelfPostOptions);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
post = await target.submitLink(data as SubmitLinkOptions);
|
||||
}
|
||||
break;
|
||||
} catch (e: any) {
|
||||
if (e.message.includes('RATELIMIT')) {
|
||||
// Looks like you've been doing that a lot. Take a break for 5 seconds before trying again
|
||||
await sleep(5000);
|
||||
error = e;
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (error !== undefined) {
|
||||
throw error;
|
||||
}
|
||||
// @ts-ignore
|
||||
return post;
|
||||
}
|
||||
|
||||
protected getSpecificPremise(): object {
|
||||
return {
|
||||
content: this.content,
|
||||
lock: this.lock,
|
||||
sticky: this.sticky,
|
||||
spoiler: this.spoiler,
|
||||
distinguish: this.distinguish,
|
||||
nsfw: this.nsfw,
|
||||
flairId: this.flairId,
|
||||
flairText: this.flairText,
|
||||
url: this.url,
|
||||
text: this.content !== undefined ? truncateStringToLength(50)(this.content) : undefined,
|
||||
footer: this.footer,
|
||||
targets: this.targets,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface SubmissionActionConfig extends RichContent, Footer {
|
||||
/**
|
||||
* Lock the Submission after creation?
|
||||
* */
|
||||
lock?: boolean,
|
||||
/**
|
||||
* Sticky the Submission after creation?
|
||||
* */
|
||||
sticky?: boolean,
|
||||
|
||||
nsfw?: boolean
|
||||
|
||||
spoiler?: boolean
|
||||
|
||||
/**
|
||||
* The title of this Submission.
|
||||
*
|
||||
* Templated the same as **content**
|
||||
* */
|
||||
title: string
|
||||
|
||||
/**
|
||||
* If Submission should be a Link, the URL to use
|
||||
*
|
||||
* Templated the same as **content**
|
||||
*
|
||||
* PROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value
|
||||
* */
|
||||
url?: string
|
||||
|
||||
/**
|
||||
* Flair template to apply to this Submission
|
||||
* */
|
||||
flairId?: string
|
||||
|
||||
/**
|
||||
* Flair text to apply to this Submission
|
||||
* */
|
||||
flairText?: string
|
||||
|
||||
/**
|
||||
* Distinguish as Mod after creation?
|
||||
* */
|
||||
distinguish?: boolean
|
||||
|
||||
/**
|
||||
* Specify where this Submission should be made
|
||||
*
|
||||
* Valid values: 'self' | [subreddit]
|
||||
*
|
||||
* * 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed
|
||||
* * [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos
|
||||
* */
|
||||
targets?: ('self' | string) | ('self' | string)[]
|
||||
}
|
||||
|
||||
export interface SubmissionActionOptions extends SubmissionActionConfig, ActionOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to the Activity. For a submission the reply will be a top-level comment.
|
||||
* */
|
||||
export interface SubmissionActionJson extends SubmissionActionConfig, ActionJson {
|
||||
kind: 'submission'
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import Comment from 'snoowrap/dist/objects/Comment';
|
||||
import {RuleResultEntity} from "../../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../../Subreddit/Manager";
|
||||
import {ActionTypes} from "../../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class FlairAction extends Action {
|
||||
text: string;
|
||||
@@ -26,7 +27,7 @@ export class FlairAction extends Action {
|
||||
return 'flair';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
let flairParts = [];
|
||||
if(this.text !== '') {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {ActionProcessResult, RuleResult} from '../Common/interfaces';
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export class UserFlairAction extends Action {
|
||||
text?: string;
|
||||
@@ -22,7 +23,7 @@ export class UserFlairAction extends Action {
|
||||
return 'userflair';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
let flairParts = [];
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {ActionProcessResult, RuleResult} from "../Common/interfaces";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes, UserNoteType} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
|
||||
export class UserNoteAction extends Action {
|
||||
@@ -27,10 +28,9 @@ export class UserNoteAction extends Action {
|
||||
return 'usernote';
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const content = await this.resources.getContent(this.content, item.subreddit);
|
||||
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
|
||||
const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string);
|
||||
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
|
||||
|
||||
if (!this.allowDuplicate) {
|
||||
|
||||
@@ -19,6 +19,8 @@ import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
import {FindOptionsWhere} from "typeorm/find-options/FindOptionsWhere";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {RunnableBaseJson, RunnableBaseOptions, StructuredRunnableBase} from "../Common/Infrastructure/Runnable";
|
||||
import { SubredditResources } from "../Subreddit/SubredditResources";
|
||||
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
||||
|
||||
export abstract class Action extends RunnableBase {
|
||||
name?: string;
|
||||
@@ -29,6 +31,8 @@ export abstract class Action extends RunnableBase {
|
||||
managerEmitter: EventEmitter;
|
||||
// actionEntity: ActionEntity | null = null;
|
||||
actionPremiseEntity: ActionPremise | null = null;
|
||||
checkName: string;
|
||||
subredditName: string;
|
||||
|
||||
constructor(options: ActionOptions) {
|
||||
super(options);
|
||||
@@ -40,6 +44,7 @@ export abstract class Action extends RunnableBase {
|
||||
subredditName,
|
||||
dryRun = false,
|
||||
emitter,
|
||||
checkName,
|
||||
} = options;
|
||||
|
||||
this.name = name;
|
||||
@@ -48,6 +53,8 @@ export abstract class Action extends RunnableBase {
|
||||
this.client = client;
|
||||
this.logger = logger.child({labels: [`Action ${this.getActionUniqueName()}`]}, mergeArr);
|
||||
this.managerEmitter = emitter;
|
||||
this.checkName = checkName;
|
||||
this.subredditName = subredditName;
|
||||
}
|
||||
|
||||
abstract getKind(): ActionTypes;
|
||||
@@ -112,7 +119,7 @@ export abstract class Action extends RunnableBase {
|
||||
}
|
||||
}
|
||||
|
||||
async handle(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionResultEntity> {
|
||||
async handle(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionResultEntity> {
|
||||
const {dryRun: runtimeDryrun} = options;
|
||||
const dryRun = runtimeDryrun || this.dryRun;
|
||||
|
||||
@@ -148,10 +155,11 @@ export abstract class Action extends RunnableBase {
|
||||
actRes.runReason = runReason;
|
||||
return actRes;
|
||||
}
|
||||
const results = await this.process(item, ruleResults, options);
|
||||
const results = await this.process(item, ruleResults, actionResults, options);
|
||||
actRes.success = results.success;
|
||||
actRes.dryRun = results.dryRun;
|
||||
actRes.result = results.result;
|
||||
actRes.data = results.data;
|
||||
actRes.touchedEntities = results.touchedEntities ?? [];
|
||||
|
||||
return actRes;
|
||||
@@ -166,20 +174,31 @@ export abstract class Action extends RunnableBase {
|
||||
}
|
||||
}
|
||||
|
||||
abstract process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult>;
|
||||
abstract process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult>;
|
||||
|
||||
getRuntimeAwareDryrun(options: runCheckOptions): boolean {
|
||||
const {dryRun: runtimeDryrun} = options;
|
||||
return runtimeDryrun || this.dryRun;
|
||||
}
|
||||
|
||||
async renderContent(template: string | undefined, item: SnoowrapActivity, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[]): Promise<string | undefined> {
|
||||
if(template === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return await this.resources.renderContent(template, item, ruleResults, actionResults, {manager: this.subredditName, check: this.checkName});
|
||||
}
|
||||
}
|
||||
|
||||
export interface ActionOptions extends Omit<ActionConfig, 'authorIs' | 'itemIs'>, RunnableBaseOptions {
|
||||
//logger: Logger;
|
||||
subredditName: string;
|
||||
//resources: SubredditResources;
|
||||
export interface ActionRuntimeOptions {
|
||||
checkName: string
|
||||
subredditName: string
|
||||
client: ExtendedSnoowrap;
|
||||
emitter: EventEmitter
|
||||
emitter: EventEmitter;
|
||||
resources: SubredditResources;
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
export interface ActionOptions extends Omit<ActionConfig, 'authorIs' | 'itemIs'>, RunnableBaseOptions, ActionRuntimeOptions {
|
||||
}
|
||||
|
||||
export interface ActionConfig extends RunnableBaseJson {
|
||||
|
||||
248
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";
|
||||
@@ -27,7 +28,15 @@ import {CommentStream, ModQueueStream, SPoll, SubmissionStream, UnmoderatedStrea
|
||||
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 +52,17 @@ 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";
|
||||
|
||||
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;
|
||||
@@ -386,7 +407,7 @@ class Bot implements BotInstanceFunctions {
|
||||
async testClient(initial = true) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const user = this.client.getMe().fetch();
|
||||
const user = await this.client.getMe().fetch();
|
||||
this.logger.info('Test API call successful');
|
||||
return user;
|
||||
} catch (err: any) {
|
||||
@@ -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(', ')}`);
|
||||
|
||||
@@ -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 {
|
||||
@@ -680,6 +710,7 @@ class Bot implements BotInstanceFunctions {
|
||||
databaseConfig: {
|
||||
retention = undefined
|
||||
} = {},
|
||||
wikiConfig = this.wikiLocation,
|
||||
} = override || {};
|
||||
|
||||
const subRepo = this.database.getRepository(SubredditEntity)
|
||||
@@ -717,7 +748,7 @@ class Bot implements BotInstanceFunctions {
|
||||
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {
|
||||
dryRun: this.dryRun,
|
||||
sharedStreams: this.sharedStreams,
|
||||
wikiLocation: this.wikiLocation,
|
||||
wikiLocation: wikiConfig,
|
||||
botName: this.botName as string,
|
||||
maxWorkers: this.maxWorkers,
|
||||
filterCriteriaDefaults: this.filterCriteriaDefaults,
|
||||
@@ -759,21 +790,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});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1235,6 +1295,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;
|
||||
|
||||
@@ -222,7 +222,14 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
|
||||
this.actions.push(actionFactory({
|
||||
...aj,
|
||||
dryRun: this.dryRun || aj.dryRun
|
||||
}, this.logger, subredditName, this.resources, this.client, this.emitter));
|
||||
}, {
|
||||
logger: this.logger,
|
||||
subredditName,
|
||||
resources: this.resources,
|
||||
client: this.client,
|
||||
emitter: this.emitter,
|
||||
checkName: this.name
|
||||
}));
|
||||
// @ts-ignore
|
||||
a.logger = this.logger;
|
||||
} else {
|
||||
@@ -564,7 +571,7 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
|
||||
const dr = dryRun || this.dryRun;
|
||||
this.logger.debug(`${dr ? 'DRYRUN - ' : ''}Running Actions`);
|
||||
for (const a of this.actions) {
|
||||
const res = await a.handle(item, ruleResults, options);
|
||||
const res = await a.handle(item, ruleResults, runActions, options);
|
||||
runActions.push(res);
|
||||
}
|
||||
this.logger.info(`${dr ? 'DRYRUN - ' : ''}Ran Actions: ${runActions.map(x => x.premise.getFriendlyIdentifier()).join(' | ')}`);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,11 @@ export class ActionResultEntity extends TimeAwareRandomBaseEntity {
|
||||
@JoinColumn({name: 'premiseId'})
|
||||
premise!: ActionPremise;
|
||||
|
||||
/**
|
||||
* Ephemeral -- only added during actual run time and used for action templating. Is not available after loading from DB.
|
||||
* */
|
||||
data?: any;
|
||||
|
||||
touchedEntities: (Submission | Comment | RedditUser | string)[] = []
|
||||
|
||||
set itemIs(data: ActivityStateFilterResult | IFilterResult<TypedActivityState> | undefined) {
|
||||
|
||||
@@ -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,199 +0,0 @@
|
||||
import {Logger} from "winston";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {StrongImageDetection} from "./interfaces";
|
||||
import ImageData from "./ImageData";
|
||||
import {bitsToHexLength, mergeArr} from "../util";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
import {ImageHashCacheData} from "./Infrastructure/Atomic";
|
||||
import leven from "leven";
|
||||
|
||||
export interface CompareImageOptions {
|
||||
config?: StrongImageDetection
|
||||
}
|
||||
|
||||
export interface ThresholdResults {
|
||||
withinHard: boolean | undefined,
|
||||
withinSoft: boolean | undefined
|
||||
}
|
||||
|
||||
export class ImageComparisonService {
|
||||
|
||||
protected reference!: ImageData
|
||||
protected resources: SubredditResources;
|
||||
protected logger: Logger;
|
||||
protected detectionConfig: StrongImageDetection;
|
||||
|
||||
constructor(resources: SubredditResources, logger: Logger, config: StrongImageDetection) {
|
||||
this.resources = resources;
|
||||
this.logger = logger.child({labels: ['Image Detection']}, mergeArr);
|
||||
this.detectionConfig = config;
|
||||
}
|
||||
|
||||
async setReference(img: ImageData, options?: CompareImageOptions) {
|
||||
this.reference = img;
|
||||
const {config = this.detectionConfig} = options || {};
|
||||
|
||||
try {
|
||||
this.reference.setPreferredResolutionByWidth(800);
|
||||
if (config.hash.enable) {
|
||||
if (config.hash.ttl !== undefined) {
|
||||
const refHash = await this.resources.getImageHash(this.reference);
|
||||
if (refHash === undefined) {
|
||||
await this.reference.hash(config.hash.bits);
|
||||
await this.resources.setImageHash(this.reference, config.hash.ttl);
|
||||
} else if (refHash.original.length !== bitsToHexLength(config.hash.bits)) {
|
||||
this.logger.warn('Reference image hash length did not correspond to bits specified in config. Recomputing...');
|
||||
await this.reference.hash(config.hash.bits);
|
||||
await this.resources.setImageHash(this.reference, config.hash.ttl);
|
||||
} else {
|
||||
this.reference.setFromHashCache(refHash);
|
||||
}
|
||||
} else {
|
||||
await this.reference.hash(config.hash.bits);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new CMError('Could not set reference image due to an error', {cause: err});
|
||||
}
|
||||
}
|
||||
|
||||
compareDiffWithThreshold(diff: number, options?: CompareImageOptions): ThresholdResults {
|
||||
const {
|
||||
config: {
|
||||
hash: {
|
||||
hardThreshold = 5,
|
||||
softThreshold = undefined,
|
||||
} = {},
|
||||
} = this.detectionConfig
|
||||
} = options || {};
|
||||
|
||||
let hard: boolean | undefined;
|
||||
let soft: boolean | undefined;
|
||||
|
||||
if ((null !== hardThreshold && undefined !== hardThreshold)) {
|
||||
hard = diff <= hardThreshold;
|
||||
if (hard) {
|
||||
return {withinHard: hard, withinSoft: hard};
|
||||
}
|
||||
}
|
||||
|
||||
if ((null !== softThreshold && undefined !== softThreshold)) {
|
||||
soft = diff <= softThreshold;
|
||||
}
|
||||
|
||||
return {withinHard: hard, withinSoft: soft};
|
||||
}
|
||||
|
||||
async compareWithCandidate(candidate: ImageData, options?: CompareImageOptions) {
|
||||
const {config = this.detectionConfig} = options || {};
|
||||
|
||||
if (config.hash.enable) {
|
||||
await this.compareCandidateHash(candidate, options);
|
||||
}
|
||||
}
|
||||
|
||||
async compareCandidateHash(candidate: ImageData, options?: CompareImageOptions) {
|
||||
const {config = this.detectionConfig} = options || {};
|
||||
|
||||
let compareHash: Required<ImageHashCacheData> | undefined;
|
||||
if (config.hash.ttl !== undefined) {
|
||||
compareHash = await this.resources.getImageHash(candidate);
|
||||
}
|
||||
if (compareHash === undefined) {
|
||||
compareHash = await candidate.hash(config.hash.bits);
|
||||
if (config.hash.ttl !== undefined) {
|
||||
await this.resources.setImageHash(candidate, config.hash.ttl);
|
||||
}
|
||||
} else {
|
||||
candidate.setFromHashCache(compareHash);
|
||||
}
|
||||
|
||||
let diff = await this.compareImageHashes(this.reference, candidate, options);
|
||||
|
||||
let threshRes = this.compareDiffWithThreshold(diff, options);
|
||||
|
||||
if(threshRes.withinSoft !== true && threshRes.withinHard !== true) {
|
||||
// up to this point we rely naively on hashes that were:
|
||||
//
|
||||
// * from cache/db for which we do not have resolutions stored (maybe fix this??)
|
||||
// * hashes generated from PREVIEWS from reddit that should be the same *width*
|
||||
//
|
||||
// we don't have control over how reddit resizes previews or the quality of the previews
|
||||
// so if we don't get a match using our initial naive, but cpu/data lite approach,
|
||||
// then we need to check original sources to see if it's possible there has been resolution/cropping trickery
|
||||
|
||||
if(this.reference.isMaybeCropped(candidate)) {
|
||||
const [normalizedRefSharp, normalizedCandidateSharp, width, height] = await this.reference.normalizeImagesForComparison('pixel', candidate, false);
|
||||
const normalizedRef = new ImageData({width, height, path: this.reference.path});
|
||||
normalizedRef.sharpImg = normalizedRefSharp;
|
||||
const normalizedCandidate = new ImageData({width, height, path: candidate.path});
|
||||
normalizedCandidate.sharpImg = normalizedCandidateSharp;
|
||||
|
||||
const normalDiff = await this.compareImageHashes(normalizedRef, normalizedCandidate, options);
|
||||
let normalizedThreshRes = this.compareDiffWithThreshold(normalDiff, options);
|
||||
}
|
||||
}
|
||||
|
||||
/* // return image if hard is defined and diff is less
|
||||
if (null !== config.hash.hardThreshold && diff <= config.hash.hardThreshold) {
|
||||
return x;
|
||||
}
|
||||
// hard is either not defined or diff was greater than hard
|
||||
|
||||
// if soft is defined
|
||||
if (config.hash.softThreshold !== undefined) {
|
||||
// and diff is greater than soft allowance
|
||||
if (diff > config.hash.softThreshold) {
|
||||
// not similar enough
|
||||
return null;
|
||||
}
|
||||
// similar enough, will continue on to pixel (if enabled!)
|
||||
} else {
|
||||
// only hard was defined and did not pass
|
||||
return null;
|
||||
}*/
|
||||
}
|
||||
|
||||
async compareImageHashes(reference: ImageData, candidate: ImageData, options?: CompareImageOptions) {
|
||||
const {config = this.detectionConfig} = options || {};
|
||||
const {
|
||||
hash: {
|
||||
bits = 16,
|
||||
} = {},
|
||||
} = config;
|
||||
|
||||
let refHash = await reference.hash(bits);
|
||||
let compareHash = await candidate.hash(bits);
|
||||
|
||||
if (compareHash.original.length !== refHash.original.length) {
|
||||
this.logger.warn(`Hash lengths were not the same! Will need to recompute compare hash to match reference.\n\nReference: ${reference.basePath} has is ${refHash.original.length} char long | Comparing: ${candidate.basePath} has is ${compareHash} ${compareHash.original.length} long`);
|
||||
refHash = await reference.hash(bits, true, true);
|
||||
compareHash = await candidate.hash(bits, true, true);
|
||||
}
|
||||
|
||||
let diff: number;
|
||||
const odistance = leven(refHash.original, compareHash.original);
|
||||
diff = (odistance / refHash.original.length) * 100;
|
||||
|
||||
// compare flipped hash if it exists
|
||||
// if it has less difference than normal comparison then the image is probably flipped (or so different it doesn't matter)
|
||||
if (compareHash.flipped !== undefined) {
|
||||
const fdistance = leven(refHash.original, compareHash.flipped);
|
||||
const fdiff = (fdistance / refHash.original.length) * 100;
|
||||
if (fdiff < diff) {
|
||||
diff = fdiff;
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
async compareCandidatePixel() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
async compareImagePixels() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
}
|
||||
@@ -42,8 +42,8 @@ class ImageData {
|
||||
return await (await this.sharp()).clone().toFormat(format).toBuffer();
|
||||
}
|
||||
|
||||
async hash(bits: number = 16, useVariantIfPossible = true, force = false): Promise<Required<ImageHashCacheData>> {
|
||||
if (force || (this.hashResult === undefined || this.hashResultFlipped === undefined)) {
|
||||
async hash(bits: number = 16, useVariantIfPossible = true): Promise<Required<ImageHashCacheData>> {
|
||||
if (this.hashResult === undefined || this.hashResultFlipped === undefined) {
|
||||
let ref: ImageData | undefined;
|
||||
if (useVariantIfPossible && this.preferredResolution !== undefined) {
|
||||
ref = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
||||
@@ -182,25 +182,6 @@ class ImageData {
|
||||
return this.width === otherImage.width && this.height === otherImage.height;
|
||||
}
|
||||
|
||||
isMaybeCropped(otherImage: ImageData, allowDiff = 10): boolean {
|
||||
if (!this.hasDimensions || !otherImage.hasDimensions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const refWidth = this.width as number;
|
||||
const refHeight = this.height as number;
|
||||
const oWidth = otherImage.width as number;
|
||||
const oHeight = otherImage.height as number;
|
||||
|
||||
const sWidth = refWidth <= oWidth ? refWidth : oWidth;
|
||||
const sHeight = refHeight <= oHeight ? refHeight : oHeight;
|
||||
|
||||
const widthDiff = sWidth / (sWidth === refWidth ? oWidth : refWidth);
|
||||
const heightDiff = sHeight / (sHeight === refHeight ? oHeight : refHeight);
|
||||
|
||||
return widthDiff <= allowDiff || heightDiff <= allowDiff;
|
||||
}
|
||||
|
||||
async sameAspectRatio(otherImage: ImageData) {
|
||||
let thisRes = this.actualResolution;
|
||||
let otherRes = otherImage.actualResolution;
|
||||
@@ -226,12 +207,12 @@ class ImageData {
|
||||
return {width: width as number, height: height as number};
|
||||
}
|
||||
|
||||
async normalizeImagesForComparison(compareLibrary: ('pixel' | 'resemble'), imgToCompare: ImageData, usePreferredResolution = true): Promise<[Sharp, Sharp, number, number]> {
|
||||
async normalizeImagesForComparison(compareLibrary: ('pixel' | 'resemble'), imgToCompare: ImageData): Promise<[Sharp, Sharp, number, number]> {
|
||||
const sFunc = await getSharpAsync();
|
||||
|
||||
let refImage = this as ImageData;
|
||||
let compareImage = imgToCompare;
|
||||
if (usePreferredResolution && this.preferredResolution !== undefined) {
|
||||
if (this.preferredResolution !== undefined) {
|
||||
const matchingVariant = compareImage.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
||||
if (matchingVariant !== undefined) {
|
||||
compareImage = matchingVariant;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {ActivityType} from "./Reddit";
|
||||
|
||||
/**
|
||||
* A duration and how to compare it against a value
|
||||
*
|
||||
@@ -15,6 +17,22 @@
|
||||
|
||||
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
|
||||
*
|
||||
@@ -148,10 +166,18 @@ export type RecordOutputOption = boolean | RecordOutputType | RecordOutputType[]
|
||||
export type PostBehaviorType = 'next' | 'stop' | 'nextRun' | string;
|
||||
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)$
|
||||
@@ -169,11 +195,17 @@ 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 =
|
||||
'comment'
|
||||
| 'submission'
|
||||
| 'lock'
|
||||
| 'remove'
|
||||
| 'report'
|
||||
@@ -282,3 +314,163 @@ export interface ImageHashCacheData {
|
||||
original?: string
|
||||
flipped?: string
|
||||
}
|
||||
|
||||
// https://www.reddit.com/message/compose?to=/r/mealtimevideos&message=https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot
|
||||
|
||||
export interface BaseTemplateData {
|
||||
botLink: string
|
||||
modmailLink?: string
|
||||
manager?: string
|
||||
check?: string
|
||||
//[key: string]: any
|
||||
}
|
||||
|
||||
export interface ActivityTemplateData {
|
||||
kind: ActivityType
|
||||
author: string
|
||||
votes: number
|
||||
age: string
|
||||
permalink: string
|
||||
id: string
|
||||
subreddit: string
|
||||
title: string
|
||||
shortTitle: string
|
||||
}
|
||||
|
||||
export interface ModdedActivityTemplateData {
|
||||
reports: number
|
||||
modReports: number
|
||||
userReports: number
|
||||
}
|
||||
|
||||
export interface SubmissionTemplateData extends ActivityTemplateData, Partial<ModdedActivityTemplateData> {
|
||||
nsfw: boolean
|
||||
spoiler: boolean
|
||||
op: boolean
|
||||
upvoteRatio: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface CommentTemplateData extends ActivityTemplateData, Partial<ModdedActivityTemplateData> {
|
||||
op: boolean
|
||||
}
|
||||
|
||||
export interface SubredditTemplateData {
|
||||
subredditBreakdownFormatted: string
|
||||
subredditBreakdown?: {
|
||||
totalFormatted: string
|
||||
submissionFormatted: string
|
||||
commentFormatted: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuleResultTemplateData {
|
||||
kind: string
|
||||
triggered: boolean
|
||||
result: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ActionResultTemplateData {
|
||||
kind: string
|
||||
success: boolean
|
||||
result: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface ActionResultsTemplateData {
|
||||
actionSummary: string
|
||||
actions: {
|
||||
[key: string]: ActionResultTemplateData
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuleResultsTemplateData {
|
||||
ruleSummary: string
|
||||
rules: {
|
||||
[key: string]: RuleResultTemplateData
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenericContentTemplateData extends BaseTemplateData, Partial<RuleResultsTemplateData>, Partial<ActionResultsTemplateData> {
|
||||
item?: (SubmissionTemplateData | CommentTemplateData)
|
||||
}
|
||||
|
||||
export type ModerationActionType =
|
||||
'banuser'
|
||||
| 'unbanuser'
|
||||
| 'spamlink'
|
||||
| 'removelink'
|
||||
| 'approvelink'
|
||||
| 'spamcomment'
|
||||
| 'removecomment'
|
||||
| 'approvecomment'
|
||||
| 'addmoderator'
|
||||
| 'showcomment'
|
||||
| 'invitemoderator'
|
||||
| 'uninvitemoderator'
|
||||
| 'acceptmoderatorinvite'
|
||||
| 'removemoderator'
|
||||
| 'addcontributor'
|
||||
| 'removecontributor'
|
||||
| 'editsettings'
|
||||
| 'editflair'
|
||||
| 'distinguish'
|
||||
| 'marknsfw'
|
||||
| 'wikibanned'
|
||||
| 'wikicontributor'
|
||||
| 'wikiunbanned'
|
||||
| 'wikipagelisted'
|
||||
| 'removewikicontributor'
|
||||
| 'wikirevise'
|
||||
| 'wikipermlevel'
|
||||
| 'ignorereports'
|
||||
| 'unignorereports'
|
||||
| 'setpermissions'
|
||||
| 'setsuggestedsort'
|
||||
| 'sticky'
|
||||
| 'unsticky'
|
||||
| 'setcontestmode'
|
||||
| 'unsetcontestmode'
|
||||
| 'lock'
|
||||
| 'unlock'
|
||||
| 'muteuser'
|
||||
| 'unmuteuser'
|
||||
| 'createrule'
|
||||
| 'editrule'
|
||||
| 'reorderrules'
|
||||
| 'deleterule'
|
||||
| 'spoiler'
|
||||
| 'unspoiler'
|
||||
| 'modmail_enrollment'
|
||||
| 'community_styling'
|
||||
| 'community_widgets'
|
||||
| 'markoriginalcontent'
|
||||
| 'collections'
|
||||
| 'events'
|
||||
| 'hidden_award'
|
||||
| 'add_community_topics'
|
||||
| 'remove_community_topics'
|
||||
| 'create_scheduled_post'
|
||||
| 'edit_scheduled_post'
|
||||
| 'delete_scheduled_post'
|
||||
| 'submit_scheduled_post'
|
||||
| 'edit_post_requirements'
|
||||
| 'invitesubscriber'
|
||||
| 'submit_content_rating_survey'
|
||||
| 'adjust_post_crowd_control_level'
|
||||
| 'enable_post_crowd_control_filter'
|
||||
| 'disable_post_crowd_control_filter'
|
||||
| 'deleteoverriddenclassification'
|
||||
| 'overrideclassification'
|
||||
| 'reordermoderators'
|
||||
| 'snoozereports'
|
||||
| 'unsnoozereports'
|
||||
| 'addnote'
|
||||
| 'deletenote'
|
||||
| 'addremovalreason'
|
||||
| 'createremovalreason'
|
||||
| 'updateremovalreason'
|
||||
| 'deleteremovalreason';
|
||||
|
||||
export const moderatorActionTypes = ['banuser', 'unbanuser', 'spamlink', 'removelink', 'approvelink', 'spamcomment', 'removecomment', 'approvecomment', 'addmoderator', 'showcomment', 'invitemoderator', 'uninvitemoderator', 'acceptmoderatorinvite', 'removemoderator', 'addcontributor', 'removecontributor', 'editsettings', 'editflair', 'distinguish', 'marknsfw', 'wikibanned', 'wikicontributor', 'wikiunbanned', 'wikipagelisted', 'removewikicontributor', 'wikirevise', 'wikipermlevel', 'ignorereports', 'unignorereports', 'setpermissions', 'setsuggestedsort', 'sticky', 'unsticky', 'setcontestmode', 'unsetcontestmode', 'lock', 'unlock', 'muteuser', 'unmuteuser', 'createrule', 'editrule', 'reorderrules', 'deleterule', 'spoiler', 'unspoiler', 'modmail_enrollment', 'community_styling', 'community_widgets', 'markoriginalcontent', 'collections', 'events', 'hidden_award', 'add_community_topics', 'remove_community_topics', 'create_scheduled_post', 'edit_scheduled_post', 'delete_scheduled_post', 'submit_scheduled_post', 'edit_post_requirements', 'invitesubscriber', 'submit_content_rating_survey', 'adjust_post_crowd_control_level', 'enable_post_crowd_control_filter', 'disable_post_crowd_control_filter', 'deleteoverriddenclassification', 'overrideclassification', 'reordermoderators', 'snoozereports', 'unsnoozereports', 'addnote', 'deletenote', 'addremovalreason', 'createremovalreason', 'updateremovalreason', 'deleteremovalreason'];
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
DurationComparor,
|
||||
ModeratorNameCriteria,
|
||||
ModeratorNames, ModActionType,
|
||||
ModUserNoteLabel
|
||||
ModUserNoteLabel, RelativeDateTimeMatch
|
||||
} from "../Atomic";
|
||||
import {ActivityType} from "../Reddit";
|
||||
import {GenericComparison, parseGenericValueComparison} from "../Comparisons";
|
||||
@@ -245,10 +245,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[],
|
||||
/**
|
||||
@@ -440,6 +441,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
|
||||
*
|
||||
|
||||
@@ -88,3 +88,18 @@ export interface SubredditRemovalReason {
|
||||
id: string,
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface SubredditActivityAbsoluteBreakdown {
|
||||
count: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SubredditActivityBreakdown extends SubredditActivityAbsoluteBreakdown {
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface SubredditActivityBreakdownByType {
|
||||
total: SubredditActivityBreakdown[]
|
||||
submission: SubredditActivityBreakdown[]
|
||||
comment: SubredditActivityBreakdown[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { MigrationInterface, QueryRunner } from "typeorm"
|
||||
import {ActionType} from "../../../Entities/ActionType";
|
||||
|
||||
export class submission1661183583080 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.manager.getRepository(ActionType).save([
|
||||
new ActionType('submission')
|
||||
]);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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.11.4';
|
||||
export const VERSION = '0.12.2';
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
DurationVal,
|
||||
EventRetentionPolicyRange,
|
||||
JoinOperands,
|
||||
NonDispatchActivitySource,
|
||||
NonDispatchActivitySourceValue,
|
||||
NotificationEventType,
|
||||
NotificationProvider,
|
||||
onExistingFoundBehavior,
|
||||
@@ -1060,6 +1060,16 @@ export interface SubredditOverrides {
|
||||
* */
|
||||
retention?: EventRetentionPolicyRange
|
||||
}
|
||||
|
||||
/**
|
||||
* The relative URL to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`
|
||||
*
|
||||
* This will override the default relative URL as well as any URL set at the bot-level
|
||||
*
|
||||
* @default "botconfig/contextbot"
|
||||
* @examples ["botconfig/contextbot"]
|
||||
* */
|
||||
wikiConfig?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1575,6 +1585,9 @@ export interface ThirdPartyCredentialsJsonConfig {
|
||||
youtube?: {
|
||||
apiKey: string
|
||||
}
|
||||
mhs?: {
|
||||
apiKey: string
|
||||
}
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@@ -1720,6 +1733,7 @@ export interface ActionProcessResult {
|
||||
dryRun: boolean,
|
||||
result?: string
|
||||
touchedEntities?: (Submission | Comment | RedditUser | string)[]
|
||||
data?: any
|
||||
}
|
||||
|
||||
export interface EventActivity {
|
||||
@@ -1953,7 +1967,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,9 +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 | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | ModNoteActionJson | string | IncludesData;
|
||||
export type ActionJson = CommentActionJson | SubmissionActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | ModNoteActionJson | string | IncludesData;
|
||||
|
||||
@@ -418,6 +418,7 @@ export class ConfigBuilder {
|
||||
}
|
||||
structuredRuns.push({
|
||||
...r,
|
||||
filterCriteriaDefaults: configFilterDefaultsFromRun,
|
||||
checks: structuredChecks,
|
||||
authorIs: derivedRunAuthorIs,
|
||||
itemIs: derivedRunItemIs
|
||||
@@ -642,7 +643,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;
|
||||
|
||||
@@ -7,9 +7,10 @@ import {Rule, RuleJSONConfig, RuleOptions} from "./index";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
asComment,
|
||||
asSubmission,
|
||||
FAIL,
|
||||
formatNumber, getActivitySubredditName, historyFilterConfigToOptions, isSubmission,
|
||||
formatNumber, getActivitySubredditName, historyFilterConfigToOptions, isComment, isSubmission,
|
||||
parseSubredditName,
|
||||
PASS,
|
||||
percentFromString, removeUndefinedKeys, toStrongSubredditState, windowConfigToWindowCriteria
|
||||
@@ -20,6 +21,7 @@ import {CompareValueOrPercent} from "../Common/Infrastructure/Atomic";
|
||||
import {ActivityWindowConfig, ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils";
|
||||
|
||||
export interface CommentThresholdCriteria extends ThresholdCriteria {
|
||||
/**
|
||||
@@ -206,10 +208,11 @@ export class HistoryRule extends Rule {
|
||||
fOpTotal = filteredCounts.opTotal;
|
||||
}
|
||||
|
||||
let asOp = false;
|
||||
let commentTrigger = undefined;
|
||||
if(comment !== undefined) {
|
||||
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(comment);
|
||||
const asOp = extra.toLowerCase().includes('op');
|
||||
asOp = extra.toLowerCase().includes('op');
|
||||
if(isPercent) {
|
||||
const per = value / 100;
|
||||
if(asOp) {
|
||||
@@ -264,7 +267,8 @@ export class HistoryRule extends Rule {
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true)
|
||||
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true),
|
||||
subredditBreakdown: getSubredditBreakdownByActivityType(!asOp ? filteredActivities : filteredActivities.filter(x => asSubmission(x) || x.is_submitter))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -296,6 +300,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})]);
|
||||
@@ -320,6 +330,7 @@ export class HistoryRule extends Rule {
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
subredditBreakdown,
|
||||
} = results;
|
||||
|
||||
const data: any = {
|
||||
@@ -338,6 +349,7 @@ export class HistoryRule extends Rule {
|
||||
submissionTrigger,
|
||||
commentTrigger,
|
||||
totalTrigger,
|
||||
subredditBreakdown
|
||||
};
|
||||
|
||||
let thresholdSummary = [];
|
||||
|
||||
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.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.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;
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {ImageHashCacheData} from "../Common/Infrastructure/Atomic";
|
||||
import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils";
|
||||
|
||||
const parseLink = parseUsableLinkIdentifier();
|
||||
|
||||
@@ -187,8 +188,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;
|
||||
@@ -508,6 +511,7 @@ export class RecentActivityRule extends Rule {
|
||||
testValue,
|
||||
karmaThreshold,
|
||||
combinedKarma,
|
||||
subredditBreakdown: getSubredditBreakdownByActivityType(activities)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -286,10 +289,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"
|
||||
@@ -704,6 +708,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -746,6 +764,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -1647,13 +1679,13 @@
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
|
||||
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
|
||||
"examples": [
|
||||
"aUserName",
|
||||
"u/aUserName",
|
||||
"r/aSubreddit"
|
||||
"r/aSubreddit",
|
||||
"r/{{item.subreddit}}"
|
||||
],
|
||||
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -2292,6 +2324,170 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionActionJson": {
|
||||
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
|
||||
"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."
|
||||
},
|
||||
"content": {
|
||||
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
|
||||
"examples": [
|
||||
"This is the content of a comment/report/usernote",
|
||||
"this is **bold** markdown text",
|
||||
"wiki:botconfig/acomment"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"distinguish": {
|
||||
"description": "Distinguish as Mod after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dryRun": {
|
||||
"default": false,
|
||||
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "If set to `false` the Action will not be run",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"flairId": {
|
||||
"description": "Flair template to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"flairText": {
|
||||
"description": "Flair text to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"footer": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CommentState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
|
||||
}
|
||||
],
|
||||
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The type of action that will be performed",
|
||||
"enum": [
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lock": {
|
||||
"description": "Lock the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
|
||||
"examples": [
|
||||
"myDescriptiveAction"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"nsfw": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"spoiler": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sticky": {
|
||||
"description": "Sticky the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
|
||||
},
|
||||
"title": {
|
||||
"description": "The title of this Submission.\n\nTemplated the same as **content**",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionState": {
|
||||
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
|
||||
"examples": [
|
||||
@@ -2326,6 +2522,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -665,10 +679,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"
|
||||
@@ -1376,6 +1391,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1447,6 +1476,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1618,6 +1650,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -1669,6 +1704,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -2163,69 +2212,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": {
|
||||
@@ -2296,62 +2282,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": {
|
||||
@@ -2855,6 +2785,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": {
|
||||
@@ -3324,6 +3303,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": {
|
||||
@@ -3437,13 +3536,13 @@
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
|
||||
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
|
||||
"examples": [
|
||||
"aUserName",
|
||||
"u/aUserName",
|
||||
"r/aSubreddit"
|
||||
"r/aSubreddit",
|
||||
"r/{{item.subreddit}}"
|
||||
],
|
||||
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -5269,6 +5368,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -5345,7 +5447,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": {
|
||||
@@ -5661,6 +5763,170 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionActionJson": {
|
||||
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
|
||||
"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."
|
||||
},
|
||||
"content": {
|
||||
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
|
||||
"examples": [
|
||||
"This is the content of a comment/report/usernote",
|
||||
"this is **bold** markdown text",
|
||||
"wiki:botconfig/acomment"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"distinguish": {
|
||||
"description": "Distinguish as Mod after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dryRun": {
|
||||
"default": false,
|
||||
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "If set to `false` the Action will not be run",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"flairId": {
|
||||
"description": "Flair template to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"flairText": {
|
||||
"description": "Flair text to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"footer": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CommentState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
|
||||
}
|
||||
],
|
||||
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The type of action that will be performed",
|
||||
"enum": [
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lock": {
|
||||
"description": "Lock the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
|
||||
"examples": [
|
||||
"myDescriptiveAction"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"nsfw": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"spoiler": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sticky": {
|
||||
"description": "Sticky the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
|
||||
},
|
||||
"title": {
|
||||
"description": "The title of this Submission.\n\nTemplated the same as **content**",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionCheckConfigData": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
@@ -5724,6 +5990,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -5895,6 +6164,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -5946,6 +6218,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -6157,6 +6443,17 @@
|
||||
"ThirdPartyCredentialsJsonConfig": {
|
||||
"additionalProperties": {},
|
||||
"properties": {
|
||||
"mhs": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"youtube": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
|
||||
@@ -42,6 +42,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -679,10 +693,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"
|
||||
@@ -1199,6 +1214,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1270,6 +1299,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1441,6 +1473,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -1492,6 +1527,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -2450,6 +2499,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": {
|
||||
@@ -2919,6 +3017,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": {
|
||||
@@ -3032,13 +3250,13 @@
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
|
||||
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
|
||||
"examples": [
|
||||
"aUserName",
|
||||
"u/aUserName",
|
||||
"r/aSubreddit"
|
||||
"r/aSubreddit",
|
||||
"r/{{item.subreddit}}"
|
||||
],
|
||||
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -4724,6 +4942,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4986,6 +5207,170 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionActionJson": {
|
||||
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
|
||||
"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."
|
||||
},
|
||||
"content": {
|
||||
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
|
||||
"examples": [
|
||||
"This is the content of a comment/report/usernote",
|
||||
"this is **bold** markdown text",
|
||||
"wiki:botconfig/acomment"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"distinguish": {
|
||||
"description": "Distinguish as Mod after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dryRun": {
|
||||
"default": false,
|
||||
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "If set to `false` the Action will not be run",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"flairId": {
|
||||
"description": "Flair template to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"flairText": {
|
||||
"description": "Flair text to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"footer": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CommentState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
|
||||
}
|
||||
],
|
||||
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The type of action that will be performed",
|
||||
"enum": [
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lock": {
|
||||
"description": "Lock the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
|
||||
"examples": [
|
||||
"myDescriptiveAction"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"nsfw": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"spoiler": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sticky": {
|
||||
"description": "Sticky the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
|
||||
},
|
||||
"title": {
|
||||
"description": "The title of this Submission.\n\nTemplated the same as **content**",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionCheckConfigData": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
@@ -5049,6 +5434,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -5220,6 +5608,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -5271,6 +5662,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -133,10 +133,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 +187,17 @@
|
||||
},
|
||||
"BotCredentialsJsonConfig": {
|
||||
"properties": {
|
||||
"mhs": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"reddit": {
|
||||
"$ref": "#/definitions/RedditCredentials"
|
||||
},
|
||||
@@ -522,6 +534,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -934,7 +960,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": {
|
||||
@@ -1381,7 +1407,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')",
|
||||
@@ -1834,6 +1860,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -2030,6 +2070,14 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"wikiConfig": {
|
||||
"default": "botconfig/contextbot",
|
||||
"description": "The relative URL to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`\n\nThis will override the default relative URL as well as any URL set at the bot-level",
|
||||
"examples": [
|
||||
"botconfig/contextbot"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2040,6 +2088,17 @@
|
||||
"ThirdPartyCredentialsJsonConfig": {
|
||||
"additionalProperties": {},
|
||||
"properties": {
|
||||
"mhs": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apiKey"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"youtube": {
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -60,6 +63,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -594,10 +611,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,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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 +1348,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": {
|
||||
@@ -1693,6 +1774,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": {
|
||||
@@ -3241,6 +3442,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -562,10 +576,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 +746,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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 +1313,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": {
|
||||
@@ -1661,6 +1739,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": {
|
||||
@@ -3209,6 +3407,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -3509,6 +3721,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -39,6 +39,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -676,10 +690,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"
|
||||
@@ -1196,6 +1211,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1267,6 +1296,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -1438,6 +1470,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -1489,6 +1524,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
@@ -1944,13 +1993,10 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilterCriteriaDefaults": {
|
||||
"FilterCriteriaDefaultsJson": {
|
||||
"properties": {
|
||||
"authorIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
@@ -1959,12 +2005,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": [
|
||||
@@ -1975,9 +2028,6 @@
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
@@ -1989,10 +2039,16 @@
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -2007,62 +2063,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": {
|
||||
@@ -2566,6 +2566,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": {
|
||||
@@ -3035,6 +3084,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": {
|
||||
@@ -3148,13 +3317,13 @@
|
||||
"type": "string"
|
||||
},
|
||||
"to": {
|
||||
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
|
||||
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
|
||||
"examples": [
|
||||
"aUserName",
|
||||
"u/aUserName",
|
||||
"r/aSubreddit"
|
||||
"r/aSubreddit",
|
||||
"r/{{item.subreddit}}"
|
||||
],
|
||||
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
@@ -4840,6 +5009,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4916,7 +5088,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": {
|
||||
@@ -5232,6 +5404,170 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionActionJson": {
|
||||
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
|
||||
"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."
|
||||
},
|
||||
"content": {
|
||||
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
|
||||
"examples": [
|
||||
"This is the content of a comment/report/usernote",
|
||||
"this is **bold** markdown text",
|
||||
"wiki:botconfig/acomment"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"distinguish": {
|
||||
"description": "Distinguish as Mod after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"dryRun": {
|
||||
"default": false,
|
||||
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
|
||||
"examples": [
|
||||
false,
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"enable": {
|
||||
"default": true,
|
||||
"description": "If set to `false` the Action will not be run",
|
||||
"examples": [
|
||||
true
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"flairId": {
|
||||
"description": "Flair template to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"flairText": {
|
||||
"description": "Flair text to apply to this Submission",
|
||||
"type": "string"
|
||||
},
|
||||
"footer": {
|
||||
"anyOf": [
|
||||
{
|
||||
"enum": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
|
||||
},
|
||||
"itemIs": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/CommentState"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
]
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
|
||||
}
|
||||
],
|
||||
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
|
||||
},
|
||||
"kind": {
|
||||
"description": "The type of action that will be performed",
|
||||
"enum": [
|
||||
"submission"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"lock": {
|
||||
"description": "Lock the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
|
||||
"examples": [
|
||||
"myDescriptiveAction"
|
||||
],
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"nsfw": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"spoiler": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sticky": {
|
||||
"description": "Sticky the Submission after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
|
||||
},
|
||||
"title": {
|
||||
"description": "The title of this Submission.\n\nTemplated the same as **content**",
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubmissionCheckConfigData": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
@@ -5295,6 +5631,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -5466,6 +5805,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/SentimentRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/MHSRuleJSONConfig"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/RuleSetConfigData"
|
||||
},
|
||||
@@ -5517,6 +5859,20 @@
|
||||
],
|
||||
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
@@ -67,7 +74,7 @@ import {
|
||||
isRateLimitError,
|
||||
isSeriousError,
|
||||
isStatusError,
|
||||
RunProcessingError
|
||||
RunProcessingError, SimpleError
|
||||
} from "../Utils/Errors";
|
||||
import {ErrorWithCause, stackWithCauses} from "pony-cause";
|
||||
import {Run} from "../Run";
|
||||
@@ -87,8 +94,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,
|
||||
@@ -121,7 +127,7 @@ export interface runCheckOptions {
|
||||
force?: boolean,
|
||||
gotoContext?: string
|
||||
maxGotoDepth?: number
|
||||
source: ActivitySource
|
||||
source: ActivitySourceValue
|
||||
initialGoto?: string
|
||||
activitySource: ActivitySourceData
|
||||
disableDispatchDelays?: boolean
|
||||
@@ -595,6 +601,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,7 +654,7 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
|
||||
this.displayLabel = nickname || `${this.subreddit.display_name_prefixed}`;
|
||||
|
||||
if (footer !== undefined) {
|
||||
if (footer !== undefined && this.resources !== undefined) {
|
||||
this.resources.footer = footer;
|
||||
}
|
||||
|
||||
@@ -660,7 +694,7 @@ 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);
|
||||
|
||||
this.logger.info('Subreddit-specific options updated');
|
||||
@@ -780,39 +814,17 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
try {
|
||||
try {
|
||||
// @ts-ignore
|
||||
wiki = await this.subreddit.getWikiPage(this.wikiLocation).fetch();
|
||||
wiki = await this.getWikiPage();
|
||||
} 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);
|
||||
@@ -841,12 +853,7 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
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() === '') {
|
||||
@@ -880,12 +887,13 @@ 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});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1799,6 +1807,104 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
async getWikiPage(location: string = this.wikiLocation) {
|
||||
let wiki: WikiPage;
|
||||
try {
|
||||
// @ts-ignore
|
||||
wiki = await this.subreddit.getWikiPage(location).fetch();
|
||||
} catch (err: any) {
|
||||
if (isStatusError(err)) {
|
||||
const error = err.statusCode === 404 ? 'does not exist' : 'is not accessible';
|
||||
let reasons = [];
|
||||
if (!this.client.scope.includes('wikiread')) {
|
||||
reasons.push(`Bot does not have 'wikiread' oauth permission`);
|
||||
} else {
|
||||
const modPermissions = await this.getModPermissions();
|
||||
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
|
||||
reasons.push(`Bot does not have required mod permissions ('all' or 'wiki') to read restricted wiki pages`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new CMError(`Wiki page ${generateFullWikiUrl(this.subreddit, location)} ${error} (${err.statusCode})${reasons.length > 0 ? ` because: ${reasons.join(' | ')}` : '.'}`, {cause: err});
|
||||
} else {
|
||||
throw new CMError(`Wiki page ${generateFullWikiUrl(this.subreddit, location)} could not be read`, {cause: err});
|
||||
}
|
||||
}
|
||||
return wiki;
|
||||
}
|
||||
|
||||
toNormalizedManager(): NormalizedManagerResponse {
|
||||
return {
|
||||
name: this.displayLabel,
|
||||
|
||||
@@ -1,39 +1,111 @@
|
||||
import {Submission, RedditUser, Comment, Subreddit, PrivateMessage} from "snoowrap/dist/objects"
|
||||
import {generateSnoowrapEntityFromRedditThing, parseRedditFullname} from "../../util"
|
||||
import Snoowrap from "snoowrap";
|
||||
import {ModerationActionType} from "../../Common/Infrastructure/Atomic";
|
||||
|
||||
//import {ExtendedSnoowrap} from "../../Utils/SnoowrapClients";
|
||||
|
||||
export interface ModActionRaw {
|
||||
action?: string | null
|
||||
action?: ModerationActionType | null
|
||||
reddit_id?: string | null
|
||||
details?: string | null
|
||||
description?: string | null
|
||||
}
|
||||
|
||||
export interface ModActionRawNormalized extends ModActionRaw {
|
||||
createdBy?: RedditUser | Subreddit
|
||||
subreddit: Subreddit
|
||||
}
|
||||
|
||||
export interface ModLogRaw {
|
||||
id: string
|
||||
mod_id36: string // wtf
|
||||
mod: string // name of moderator that performed the action
|
||||
target_fullname: string // ThingID IE t3_wuywlr
|
||||
target_author: string
|
||||
details: string // flair_edit
|
||||
action: ModerationActionType
|
||||
description: string
|
||||
target_body: string
|
||||
subreddit_name_prefixed: string
|
||||
subreddit: Subreddit // proxy object
|
||||
created_utc: number
|
||||
}
|
||||
|
||||
export class ModAction {
|
||||
action?: string
|
||||
action?: ModerationActionType
|
||||
actedOn?: RedditUser | Submission | Comment | Subreddit | PrivateMessage
|
||||
details?: string
|
||||
description?: string
|
||||
createdBy?: RedditUser | Subreddit
|
||||
subreddit?: Subreddit
|
||||
|
||||
constructor(data: ModActionRaw | undefined, client: Snoowrap) {
|
||||
const {
|
||||
action,
|
||||
reddit_id,
|
||||
details,
|
||||
description
|
||||
} = data || {};
|
||||
this.action = action !== null ? action : undefined;
|
||||
this.details = details !== null ? details : undefined;
|
||||
this.description = description !== null ? description : undefined;
|
||||
constructor(data: ModActionRawNormalized | ModLogRaw | undefined, client: Snoowrap, subreddit?: Subreddit) {
|
||||
if(data !== undefined) {
|
||||
const {
|
||||
action,
|
||||
details,
|
||||
description
|
||||
} = data || {};
|
||||
|
||||
if (reddit_id !== null && reddit_id !== undefined) {
|
||||
const thing = parseRedditFullname(reddit_id);
|
||||
if (thing !== undefined) {
|
||||
this.actedOn = generateSnoowrapEntityFromRedditThing(thing, client);
|
||||
if(subreddit !== undefined) {
|
||||
this.subreddit = subreddit;
|
||||
}
|
||||
|
||||
if(asModActionRaw(data)) {
|
||||
const {
|
||||
reddit_id,
|
||||
createdBy,
|
||||
subreddit: subFromData
|
||||
} = data as ModActionRawNormalized || {};
|
||||
|
||||
this.createdBy = createdBy;
|
||||
if(this.subreddit === undefined) {
|
||||
this.subreddit = subFromData;
|
||||
}
|
||||
|
||||
if (reddit_id !== null && reddit_id !== undefined) {
|
||||
const thing = parseRedditFullname(reddit_id);
|
||||
if (thing !== undefined) {
|
||||
this.actedOn = generateSnoowrapEntityFromRedditThing(thing, client);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const {
|
||||
target_fullname,
|
||||
target_author,
|
||||
mod,
|
||||
mod_id36,
|
||||
subreddit: subFromData
|
||||
} = data || {};
|
||||
|
||||
if (target_fullname !== null && target_fullname !== undefined) {
|
||||
const thing = parseRedditFullname(target_fullname);
|
||||
if (thing !== undefined) {
|
||||
this.actedOn = generateSnoowrapEntityFromRedditThing(thing, client);
|
||||
if (this.actedOn instanceof RedditUser) {
|
||||
this.actedOn.name = target_author;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const author = parseRedditFullname(`t2_${mod_id36}`);
|
||||
if(author !== undefined) {
|
||||
this.createdBy = generateSnoowrapEntityFromRedditThing(author, client) as RedditUser;
|
||||
if (this.createdBy instanceof RedditUser) {
|
||||
this.createdBy.name = mod;
|
||||
}
|
||||
}
|
||||
if(this.subreddit === undefined) {
|
||||
this.subreddit = subFromData;
|
||||
}
|
||||
}
|
||||
|
||||
this.action = action !== null ? action : undefined;
|
||||
this.details = details !== null ? details : undefined;
|
||||
this.description = description !== null ? description : undefined;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
toRaw(): ModActionRaw {
|
||||
@@ -50,4 +122,8 @@ export class ModAction {
|
||||
}
|
||||
}
|
||||
|
||||
export const asModActionRaw = (data: any): data is ModActionRaw => {
|
||||
return data !== null && 'reddit_id' in data;
|
||||
}
|
||||
|
||||
export default ModAction;
|
||||
|
||||
@@ -81,7 +81,7 @@ export class ModNote {
|
||||
this.createdBy.name = data.operator;
|
||||
}
|
||||
|
||||
this.action = new ModAction(data.mod_action_data, client);
|
||||
this.action = new ModAction({...data.mod_action_data, createdBy: this.createdBy, subreddit: this.subreddit}, client);
|
||||
if (this.action.actedOn instanceof RedditUser && this.action.actedOn.id === this.user.id) {
|
||||
this.action.actedOn = this.user;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import objectHash from 'object-hash';
|
||||
import {
|
||||
activityIsDeleted, activityIsFiltered,
|
||||
activityIsRemoved,
|
||||
AuthorTypedActivitiesOptions, BOT_LINK,
|
||||
AuthorTypedActivitiesOptions, BOT_LINK, TemplateContext,
|
||||
getAuthorHistoryAPIOptions, renderContent
|
||||
} from "../Utils/SnoowrapUtils";
|
||||
import {map as mapAsync} from 'async';
|
||||
@@ -41,7 +41,6 @@ import {
|
||||
redisScanIterator,
|
||||
removeUndefinedKeys,
|
||||
shouldCacheSubredditStateCriteriaResult,
|
||||
strToActivitySource,
|
||||
subredditStateIsNameOnly,
|
||||
testMaybeStringRegex,
|
||||
toStrongSubredditState,
|
||||
@@ -58,7 +57,7 @@ import {
|
||||
filterByTimeRequirement,
|
||||
asSubreddit,
|
||||
modActionCriteriaSummary,
|
||||
parseRedditFullname, asStrongImageHashCache
|
||||
parseRedditFullname, asStrongImageHashCache, matchesRelativeDateTime, generateFullWikiUrl
|
||||
} from "../util";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
@@ -119,12 +118,12 @@ import {
|
||||
UserNoteCriteria
|
||||
} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
import {
|
||||
ActivitySource, ConfigFragmentValidationFunc, DurationVal,
|
||||
ActivitySourceValue, ConfigFragmentValidationFunc, DurationVal,
|
||||
EventRetentionPolicyRange, ImageHashCacheData,
|
||||
JoinOperands,
|
||||
ModActionType,
|
||||
ModeratorNameCriteria, ModUserNoteLabel, statFrequencies, StatisticFrequency,
|
||||
StatisticFrequencyOption
|
||||
ModeratorNameCriteria, ModUserNoteLabel, RelativeDateTimeMatch, statFrequencies, StatisticFrequency,
|
||||
StatisticFrequencyOption, WikiContext
|
||||
} from "../Common/Infrastructure/Atomic";
|
||||
import {
|
||||
AuthorOptions, FilterCriteriaPropertyResult,
|
||||
@@ -161,8 +160,10 @@ import {IncludesData} from "../Common/Infrastructure/Includes";
|
||||
import {parseFromJsonOrYamlToObject} from "../Common/Config/ConfigUtil";
|
||||
import ConfigParseError from "../Utils/ConfigParseError";
|
||||
import {ActivityReport} from "../Common/Entities/ActivityReport";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
import {ActivitySource} from "../Common/ActivitySource";
|
||||
|
||||
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
|
||||
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you have any ideas, questions, or concerns about this action.';
|
||||
|
||||
/**
|
||||
* Only used for migrating stats from cache to db
|
||||
@@ -222,7 +223,7 @@ export class SubredditResources {
|
||||
protected useSubredditAuthorCache!: boolean;
|
||||
protected authorTTL: number | false = cacheTTLDefaults.authorTTL;
|
||||
protected subredditTTL: number | false = cacheTTLDefaults.subredditTTL;
|
||||
protected wikiTTL: number | false = cacheTTLDefaults.wikiTTL;
|
||||
public wikiTTL: number | false = cacheTTLDefaults.wikiTTL;
|
||||
protected submissionTTL: number | false = cacheTTLDefaults.submissionTTL;
|
||||
protected commentTTL: number | false = cacheTTLDefaults.commentTTL;
|
||||
protected filterCriteriaTTL: number | false = cacheTTLDefaults.filterCriteriaTTL;
|
||||
@@ -994,7 +995,7 @@ export class SubredditResources {
|
||||
hash = `sub-${item.name}`;
|
||||
if (tryToFetch && item instanceof Submission) {
|
||||
// @ts-ignore
|
||||
const itemToCache = await item.fetch();
|
||||
const itemToCache = await item.refresh();
|
||||
await this.cache.set(hash, itemToCache, {ttl: this.submissionTTL});
|
||||
return itemToCache;
|
||||
} else {
|
||||
@@ -1006,7 +1007,7 @@ export class SubredditResources {
|
||||
hash = `comm-${item.name}`;
|
||||
if (tryToFetch && item instanceof Comment) {
|
||||
// @ts-ignore
|
||||
const itemToCache = await item.fetch();
|
||||
const itemToCache = await item.refresh();
|
||||
await this.cache.set(hash, itemToCache, {ttl: this.commentTTL});
|
||||
return itemToCache;
|
||||
} else {
|
||||
@@ -1016,8 +1017,12 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
return item;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Error occurred while trying to add Activity to cache', {cause: e});
|
||||
} catch (e: any) {
|
||||
if(e.message !== undefined && e.message.includes('Cannot read properties of undefined (reading \'constructor\')')) {
|
||||
throw new ErrorWithCause('Error occurred while trying to add Activity to cache (Comment likely does not exist)', {cause: e});
|
||||
} else {
|
||||
throw new ErrorWithCause('Error occurred while trying to add Activity to cache', {cause: e});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,8 +1107,9 @@ export class SubredditResources {
|
||||
return subreddit as Subreddit;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error('Error while trying to fetch a cached subreddit', err);
|
||||
throw err.logged;
|
||||
const cmError = new CMError('Error while trying to fetch a cached subreddit', {cause: err, logged: true});
|
||||
this.logger.error(cmError);
|
||||
throw cmError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1135,6 +1141,18 @@ export class SubredditResources {
|
||||
return mods;
|
||||
}
|
||||
|
||||
async getSubredditModeratorPermissions(rawUserVal: RedditUser | string, rawSubredditVal?: Subreddit | string): Promise<string[]> {
|
||||
const mods = await this.getSubredditModerators(rawSubredditVal);
|
||||
const user = rawUserVal instanceof RedditUser ? rawUserVal.name : rawUserVal;
|
||||
|
||||
const mod = mods.find(x => x.name.toLowerCase() === user.toLowerCase());
|
||||
if(mod === undefined) {
|
||||
return [];
|
||||
}
|
||||
// @ts-ignore
|
||||
return mod.mod_permissions as string[];
|
||||
}
|
||||
|
||||
async getSubredditContributors(): Promise<RedditUser[]> {
|
||||
const subName = this.subreddit.display_name;
|
||||
const hash = `sub-${subName}-contributors`;
|
||||
@@ -1679,21 +1697,31 @@ export class SubredditResources {
|
||||
return filteredListing;
|
||||
}
|
||||
|
||||
async getExternalResource(val: string, subredditArg?: Subreddit): Promise<{val: string, fromCache: boolean, response?: Response, hash?: string}> {
|
||||
const subreddit = subredditArg || this.subreddit;
|
||||
let cacheKey;
|
||||
const wikiContext = parseWikiContext(val);
|
||||
if (wikiContext !== undefined) {
|
||||
cacheKey = `${subreddit.display_name}-content-${wikiContext.wiki}${wikiContext.subreddit !== undefined ? `|${wikiContext.subreddit}` : ''}`;
|
||||
}
|
||||
const extUrl = wikiContext === undefined ? parseExternalUrl(val) : undefined;
|
||||
if (extUrl !== undefined) {
|
||||
cacheKey = extUrl;
|
||||
async getExternalResource(val: string, subredditArg?: Subreddit, defaultTo: 'url' | 'wiki' | undefined = undefined): Promise<{ val: string, fromCache: boolean, response?: Response, hash?: string }> {
|
||||
let wikiContext = parseWikiContext(val);
|
||||
|
||||
let extUrl = wikiContext === undefined ? parseExternalUrl(val) : undefined;
|
||||
|
||||
if (extUrl === undefined && wikiContext === undefined) {
|
||||
if (defaultTo === 'url') {
|
||||
extUrl = val;
|
||||
} else if (defaultTo === 'wiki') {
|
||||
wikiContext = {wiki: val};
|
||||
}
|
||||
}
|
||||
|
||||
if (cacheKey === undefined) {
|
||||
return {val, fromCache: false, hash: cacheKey};
|
||||
if (wikiContext !== undefined) {
|
||||
return await this.getWikiPage(wikiContext, subredditArg !== undefined ? subredditArg.display_name : undefined);
|
||||
}
|
||||
if (extUrl !== undefined) {
|
||||
return await this.getCachedUrlResult(extUrl);
|
||||
}
|
||||
|
||||
return {val, fromCache: false};
|
||||
}
|
||||
|
||||
async getCachedUrlResult(extUrl: string): Promise<{ val: string, fromCache: boolean, response?: Response, hash?: string }> {
|
||||
const cacheKey = extUrl;
|
||||
|
||||
// try to get cached value first
|
||||
if (this.wikiTTL !== false) {
|
||||
@@ -1709,46 +1737,60 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
|
||||
let wikiContent: string;
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
const [wikiContentVal, responseVal] = await fetchExternalResult(extUrl as string, this.logger);
|
||||
return {val: wikiContentVal, fromCache: false, response: responseVal, hash: cacheKey};
|
||||
} catch (err: any) {
|
||||
throw new CMError(`Error occurred while trying to fetch the url ${extUrl}`, {cause: err});
|
||||
}
|
||||
}
|
||||
|
||||
// no cache hit, get from source
|
||||
if (wikiContext !== undefined) {
|
||||
let sub;
|
||||
if (wikiContext.subreddit === undefined || wikiContext.subreddit.toLowerCase() === subreddit.display_name) {
|
||||
sub = subreddit;
|
||||
async getWikiPage(data: WikiContext, subredditArg?: string): Promise<{ val: string, fromCache: boolean, response?: Response, hash?: string }> {
|
||||
const {
|
||||
subreddit = subredditArg ?? this.subreddit.display_name,
|
||||
wiki
|
||||
} = data;
|
||||
|
||||
const cacheKey = `${subreddit}-content-${wiki}${data.subreddit !== undefined ? `|${data.subreddit}` : ''}`;
|
||||
|
||||
if (this.wikiTTL !== false) {
|
||||
await this.stats.cache.content.identifierRequestCount.set(cacheKey, (await this.stats.cache.content.identifierRequestCount.wrap(cacheKey, () => 0) as number) + 1);
|
||||
this.stats.cache.content.requestTimestamps.push(Date.now());
|
||||
this.stats.cache.content.requests++;
|
||||
const cachedContent = await this.cache.get(cacheKey);
|
||||
if (cachedContent !== undefined && cachedContent !== null) {
|
||||
this.logger.debug(`Content Cache Hit: ${cacheKey}`);
|
||||
return {val: cachedContent as string, fromCache: true, hash: cacheKey};
|
||||
} else {
|
||||
sub = this.client.getSubreddit(wikiContext.subreddit);
|
||||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
const wikiPage = sub.getWikiPage(wikiContext.wiki);
|
||||
wikiContent = await wikiPage.content_md;
|
||||
} catch (err: any) {
|
||||
let msg = `Could not read wiki page for an unknown reason. Please ensure the page 'https://reddit.com${sub.display_name_prefixed}/wiki/${wikiContext.wiki}' exists and is readable`;
|
||||
if(err.statusCode !== undefined) {
|
||||
if(err.statusCode === 404) {
|
||||
msg = `Could not find a wiki page at https://reddit.com${sub.display_name_prefixed}/wiki/${wikiContext.wiki} -- Reddit returned a 404`;
|
||||
} else if(err.statusCode === 403 || err.statusCode === 401) {
|
||||
msg = `Bot either does not have permission visibility permissions for the wiki page at https://reddit.com${sub.display_name_prefixed}wiki/${wikiContext.wiki} (due to subreddit restrictions) or the bot does have have oauth permissions to read wiki pages (operator error). Reddit returned a ${err.statusCode}`;
|
||||
}
|
||||
}
|
||||
this.logger.error(msg, err);
|
||||
throw new LoggedError(msg);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const [wikiContentVal, responseVal] = await fetchExternalResult(extUrl as string, this.logger);
|
||||
wikiContent = wikiContentVal;
|
||||
response = responseVal;
|
||||
} catch (err: any) {
|
||||
const msg = `Error occurred while trying to fetch the url ${extUrl}`;
|
||||
this.logger.error(msg, err);
|
||||
throw new LoggedError(msg);
|
||||
this.stats.cache.content.miss++;
|
||||
}
|
||||
}
|
||||
|
||||
return {val: wikiContent, fromCache: false, response, hash: cacheKey};
|
||||
let sub = this.client.getSubreddit(subreddit);
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const wikiPage = sub.getWikiPage(wiki);
|
||||
const wikiContent = await wikiPage.content_md;
|
||||
return {val: wikiContent, fromCache: false, hash: cacheKey};
|
||||
} catch (err: any) {
|
||||
if (isStatusError(err)) {
|
||||
const error = err.statusCode === 404 ? 'does not exist' : 'is not accessible';
|
||||
let reasons = [];
|
||||
if (!this.client.scope.includes('wikiread')) {
|
||||
reasons.push(`Bot does not have 'wikiread' oauth permission`);
|
||||
} else {
|
||||
const modPermissions = await this.getSubredditModeratorPermissions(this.botName, subreddit);
|
||||
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
|
||||
reasons.push(`Bot does not have required mod permissions ('all' or 'wiki') to read restricted wiki pages`);
|
||||
}
|
||||
}
|
||||
|
||||
throw new CMError(`Wiki page ${generateFullWikiUrl(subreddit, wiki)} ${error} (${err.statusCode})${reasons.length > 0 ? `because: ${reasons.join(' | ')}` : '.'}`, {cause: err});
|
||||
} else {
|
||||
throw new CMError(`Wiki page ${generateFullWikiUrl(subreddit, wiki)} could not be read`, {cause: err});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getContent(val: string, subredditArg?: Subreddit): Promise<string> {
|
||||
@@ -1764,9 +1806,24 @@ export class SubredditResources {
|
||||
/**
|
||||
* Convenience method for using getContent and SnoowrapUtils@renderContent in one method
|
||||
* */
|
||||
async renderContent(contentStr: string, data: SnoowrapActivity, ruleResults: RuleResultEntity[] = [], usernotes?: UserNotes) {
|
||||
async renderContent(contentStr: string, activity: SnoowrapActivity, ruleResults: RuleResultEntity[] = [], actionResults: ActionResultEntity[] = [], templateData: TemplateContext = {}) {
|
||||
const content = await this.getContent(contentStr);
|
||||
return await renderContent(content, data, ruleResults, usernotes ?? this.userNotes);
|
||||
|
||||
const {usernotes = this.userNotes, ...restData} = templateData;
|
||||
return await renderContent(content, {
|
||||
...restData,
|
||||
activity,
|
||||
usernotes,
|
||||
ruleResults,
|
||||
actionResults,
|
||||
});
|
||||
}
|
||||
|
||||
async renderFooter(item: Submission | Comment, footer: false | string | undefined = this.footer) {
|
||||
if (footer === false) {
|
||||
return '';
|
||||
}
|
||||
return this.renderContent(footer, item);
|
||||
}
|
||||
|
||||
async getConfigFragment<T>(includesData: IncludesData, validateFunc?: ConfigFragmentValidationFunc): Promise<T> {
|
||||
@@ -1888,6 +1945,11 @@ export class SubredditResources {
|
||||
includeIdentifier = false,
|
||||
} = options || {};
|
||||
|
||||
// return early if there are no states to filter by!
|
||||
if(states.length === 0) {
|
||||
return items;
|
||||
}
|
||||
|
||||
let passedItems: (Comment | Submission)[] = [];
|
||||
let unpassedItems: (Comment | Submission)[] = [];
|
||||
|
||||
@@ -2028,7 +2090,7 @@ export class SubredditResources {
|
||||
return res;
|
||||
}
|
||||
|
||||
async testItemCriteria(i: (Comment | Submission), activityStateObj: NamedCriteria<TypedActivityState>, logger: Logger, include = true, source?: ActivitySource): Promise<FilterCriteriaResult<TypedActivityState>> {
|
||||
async testItemCriteria(i: (Comment | Submission), activityStateObj: NamedCriteria<TypedActivityState>, logger: Logger, include = true, source?: ActivitySourceValue): Promise<FilterCriteriaResult<TypedActivityState>> {
|
||||
const {criteria: activityState} = activityStateObj;
|
||||
if(Object.keys(activityState).length === 0) {
|
||||
return {
|
||||
@@ -2192,7 +2254,7 @@ export class SubredditResources {
|
||||
})() as boolean;
|
||||
}
|
||||
|
||||
async isItem (item: Submission | Comment, stateCriteria: TypedActivityState, logger: Logger, include: boolean, source?: ActivitySource): Promise<FilterCriteriaResult<(SubmissionState & CommentState)>> {
|
||||
async isItem (item: Submission | Comment, stateCriteria: TypedActivityState, logger: Logger, include: boolean, source?: ActivitySourceValue): Promise<FilterCriteriaResult<(SubmissionState & CommentState)>> {
|
||||
|
||||
//const definedStateCriteria = (removeUndefinedKeys(stateCriteria) as RequiredItemCrit);
|
||||
|
||||
@@ -2283,10 +2345,12 @@ export class SubredditResources {
|
||||
} else {
|
||||
propResultsMap.source!.found = source;
|
||||
|
||||
const requestedSourcesVal: string[] = !Array.isArray(itemOptVal) ? [itemOptVal] as string[] : itemOptVal as string[];
|
||||
const requestedSources = requestedSourcesVal.map(x => strToActivitySource(x).toLowerCase());
|
||||
const itemSource = new ActivitySource(source);
|
||||
|
||||
propResultsMap.source!.passed = criteriaPassWithIncludeBehavior(requestedSources.some(x => source.toLowerCase().trim() === x.toLowerCase().trim()), include);
|
||||
const requestedSourcesVal: string[] = !Array.isArray(itemOptVal) ? [itemOptVal] as string[] : itemOptVal as string[];
|
||||
const requestedSources = requestedSourcesVal.map(x => new ActivitySource(x));
|
||||
|
||||
propResultsMap.source!.passed = criteriaPassWithIncludeBehavior(requestedSources.some(x => x.matches(itemSource)), include);
|
||||
break;
|
||||
}
|
||||
case 'score':
|
||||
@@ -2422,6 +2486,23 @@ export class SubredditResources {
|
||||
propResultsMap.age!.passed = criteriaPassWithIncludeBehavior(ageTest, include);
|
||||
propResultsMap.age!.found = created.format('MMMM D, YYYY h:mm A Z');
|
||||
break;
|
||||
case 'createdOn':
|
||||
const createdAt = dayjs.unix(await item.created);
|
||||
propResultsMap.createdOn!.found = createdAt.format('MMMM D, YYYY h:mm A Z');
|
||||
propResultsMap.createdOn!.passed = false;
|
||||
|
||||
const expressions = Array.isArray(itemOptVal) ? itemOptVal as RelativeDateTimeMatch[] : [itemOptVal] as RelativeDateTimeMatch[];
|
||||
try {
|
||||
for (const expr of expressions) {
|
||||
if (matchesRelativeDateTime(expr, createdAt)) {
|
||||
propResultsMap.createdOn!.passed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch(err: any) {
|
||||
propResultsMap.createdOn!.reason = err.message;
|
||||
}
|
||||
break;
|
||||
case 'title':
|
||||
if(asComment(item)) {
|
||||
const titleWarn ='`title` is not allowed in `itemIs` criteria when the main Activity is a Comment';
|
||||
@@ -2759,7 +2840,7 @@ export class SubredditResources {
|
||||
const authPass = () => {
|
||||
|
||||
for (const n of nameVal) {
|
||||
if (n.toLowerCase() === authorName.toLowerCase()) {
|
||||
if (testMaybeStringRegex(n, authorName)[0]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -3340,19 +3421,6 @@ export class SubredditResources {
|
||||
this.logger.debug(`Cached check result '${result.check.name}' for User ${userName} on Submission ${item.link_id} for ${ttl} seconds (Hash ${hash})`);
|
||||
}
|
||||
|
||||
async generateFooter(item: Submission | Comment, actionFooter?: false | string) {
|
||||
let footer = actionFooter !== undefined ? actionFooter : this.footer;
|
||||
if (footer === false) {
|
||||
return '';
|
||||
}
|
||||
const subName = await item.subreddit.display_name;
|
||||
const permaLink = `https://reddit.com${await item.permalink}`
|
||||
const modmailLink = `https://www.reddit.com/message/compose?to=%2Fr%2F${subName}&message=${encodeURIComponent(permaLink)}`
|
||||
|
||||
const footerRawContent = await this.getContent(footer, item.subreddit);
|
||||
return he.decode(Mustache.render(footerRawContent, {subName, permaLink, modmailLink, botLink: BOT_LINK}));
|
||||
}
|
||||
|
||||
async getImageHash(img: ImageData): Promise<Required<ImageHashCacheData>|undefined> {
|
||||
|
||||
if(img.hashResult !== undefined && img.hashResultFlipped !== undefined) {
|
||||
@@ -3720,7 +3788,7 @@ export const checkAuthorFilter = async (item: (Submission | Comment), filter: Au
|
||||
return [true, undefined, {criteriaResults: allCritResults, join: 'OR', passed: true}];
|
||||
}
|
||||
|
||||
export const checkItemFilter = async (item: (Submission | Comment), filter: ItemOptions, resources: SubredditResources, options?: {logger?: Logger, source?: ActivitySource, includeIdentifier?: boolean}): Promise<[boolean, ('inclusive' | 'exclusive' | undefined), FilterResult<TypedActivityState>]> => {
|
||||
export const checkItemFilter = async (item: (Submission | Comment), filter: ItemOptions, resources: SubredditResources, options?: {logger?: Logger, source?: ActivitySourceValue, includeIdentifier?: boolean}): Promise<[boolean, ('inclusive' | 'exclusive' | undefined), FilterResult<TypedActivityState>]> => {
|
||||
|
||||
const {
|
||||
logger: parentLogger = NoopLogger,
|
||||
@@ -3878,7 +3946,7 @@ export const checkItemFilter = async (item: (Submission | Comment), filter: Item
|
||||
return [true, undefined, {criteriaResults: allCritResults, join: 'OR', passed: true}];
|
||||
}
|
||||
|
||||
export const checkCommentSubmissionStates = async (item: Comment, submissionStates: SubmissionState[], resources: SubredditResources, logger: Logger, source?: ActivitySource, excludeCondition?: JoinOperands): Promise<[boolean, FilterCriteriaPropertyResult<CommentState>]> => {
|
||||
export const checkCommentSubmissionStates = async (item: Comment, submissionStates: SubmissionState[], resources: SubredditResources, logger: Logger, source?: ActivitySourceValue, excludeCondition?: JoinOperands): Promise<[boolean, FilterCriteriaPropertyResult<CommentState>]> => {
|
||||
// test submission state first since it's more likely(??) we have crit results or cache data for this submission than for the comment
|
||||
|
||||
// get submission
|
||||
|
||||
@@ -3,7 +3,7 @@ import {Submission, Subreddit, Comment} from "snoowrap/dist/objects";
|
||||
import {parseSubredditName} from "../util";
|
||||
import {ModUserNoteLabel} from "../Common/Infrastructure/Atomic";
|
||||
import {CreateModNoteData, ModNote, ModNoteRaw, ModNoteSnoowrapPopulated} from "../Subreddit/ModNotes/ModNote";
|
||||
import {CMError, SimpleError} from "./Errors";
|
||||
import {CMError, isStatusError, SimpleError} from "./Errors";
|
||||
import {RawSubredditRemovalReasonData, SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
||||
|
||||
// const proxyFactory = (endpoint: string) => {
|
||||
@@ -66,6 +66,28 @@ export class ExtendedSnoowrap extends Snoowrap {
|
||||
return await this.oauthRequest({uri: '/api/info', method: 'get', qs: { sr_name: names.join(',')}}) as Listing<Subreddit>;
|
||||
}
|
||||
|
||||
async subredditExists(name: string): Promise<[boolean, Subreddit?]> {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const sub = await this.getSubreddit(name).fetch();
|
||||
return [true, sub];
|
||||
} catch (e: any) {
|
||||
if (isStatusError(e)) {
|
||||
switch (e.statusCode) {
|
||||
case 403:
|
||||
// we know that the sub exists but it is private
|
||||
return [true, undefined];
|
||||
case 404:
|
||||
return [false, undefined];
|
||||
default:
|
||||
throw e;
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async assignUserFlairByTemplateId(options: { flairTemplateId: string, username: string, subredditName: string }): Promise<any> {
|
||||
return await this.oauthRequest({
|
||||
uri: `/r/${options.subredditName}/api/selectflair`,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
asStrongSubredditState,
|
||||
asSubmission,
|
||||
convertSubredditsRawToStrong,
|
||||
formatNumber,
|
||||
getActivityAuthorName,
|
||||
getActivitySubredditName,
|
||||
isStrongSubredditState, isSubmission,
|
||||
@@ -22,7 +23,7 @@ import {
|
||||
normalizeName,
|
||||
parseDurationValToDuration,
|
||||
parseRedditEntity,
|
||||
parseRuleResultsToMarkdownSummary, removeUndefinedKeys,
|
||||
parseResultsToMarkdownSummary, removeUndefinedKeys,
|
||||
subredditStateIsNameOnly,
|
||||
toStrongSubredditState,
|
||||
truncateStringToLength,
|
||||
@@ -34,8 +35,14 @@ import {URL} from "url";
|
||||
import {isStatusError, MaybeSeriousErrorWithCause, SimpleError} from "./Errors";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {StrongSubredditCriteria, SubredditCriteria} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
import {DurationVal} from "../Common/Infrastructure/Atomic";
|
||||
import {DurationVal, GenericContentTemplateData} from "../Common/Infrastructure/Atomic";
|
||||
import {ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {
|
||||
SnoowrapActivity,
|
||||
SubredditActivityAbsoluteBreakdown,
|
||||
SubredditActivityBreakdown, SubredditActivityBreakdownByType
|
||||
} from "../Common/Infrastructure/Reddit";
|
||||
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
|
||||
|
||||
export const BOT_LINK = 'https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot';
|
||||
|
||||
@@ -119,73 +126,184 @@ export const isSubreddit = async (subreddit: Subreddit, stateCriteria: Subreddit
|
||||
const renderContentCommentTruncate = truncateStringToLength(50);
|
||||
const shortTitleTruncate = truncateStringToLength(15);
|
||||
|
||||
export const renderContent = async (template: string, data: (Submission | Comment), ruleResults: RuleResultEntity[] = [], usernotes: UserNotes) => {
|
||||
const conditional: any = {};
|
||||
if(data.can_mod_post) {
|
||||
conditional.reports = data.num_reports;
|
||||
conditional.modReports = data.mod_reports.length;
|
||||
conditional.userReports = data.user_reports.length;
|
||||
}
|
||||
if(asSubmission(data)) {
|
||||
conditional.nsfw = data.over_18;
|
||||
conditional.spoiler = data.spoiler;
|
||||
conditional.op = true;
|
||||
conditional.upvoteRatio = `${data.upvote_ratio * 100}%`;
|
||||
} else {
|
||||
conditional.op = data.is_submitter;
|
||||
}
|
||||
const templateData: any = {
|
||||
kind: data instanceof Submission ? 'submission' : 'comment',
|
||||
// @ts-ignore
|
||||
author: getActivityAuthorName(await data.author),
|
||||
votes: data.score,
|
||||
age: dayjs.duration(dayjs().diff(dayjs.unix(data.created))).humanize(),
|
||||
permalink: `https://reddit.com${data.permalink}`,
|
||||
export interface TemplateContext {
|
||||
usernotes?: UserNotes
|
||||
check?: string
|
||||
manager?: string
|
||||
ruleResults?: RuleResultEntity[]
|
||||
actionResults?: ActionResultEntity[]
|
||||
activity?: SnoowrapActivity
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export const renderContent = async (template: string, data: TemplateContext = {}) => {
|
||||
const {
|
||||
usernotes,
|
||||
ruleResults,
|
||||
actionResults,
|
||||
activity,
|
||||
...restContext
|
||||
} = data;
|
||||
|
||||
let view: GenericContentTemplateData = {
|
||||
botLink: BOT_LINK,
|
||||
id: data.name,
|
||||
...conditional
|
||||
...restContext
|
||||
};
|
||||
|
||||
if(activity !== undefined) {
|
||||
const conditional: any = {};
|
||||
if (activity.can_mod_post) {
|
||||
conditional.reports = activity.num_reports;
|
||||
conditional.modReports = activity.mod_reports.length;
|
||||
conditional.userReports = activity.user_reports.length;
|
||||
}
|
||||
if (asSubmission(activity)) {
|
||||
conditional.nsfw = activity.over_18;
|
||||
conditional.spoiler = activity.spoiler;
|
||||
conditional.op = true;
|
||||
conditional.upvoteRatio = `${activity.upvote_ratio * 100}%`;
|
||||
} else {
|
||||
conditional.op = activity.is_submitter;
|
||||
}
|
||||
|
||||
const subreddit = activity.subreddit.display_name;
|
||||
const permalink = `https://reddit.com${activity.permalink}`;
|
||||
|
||||
view.modmailLink = `https://www.reddit.com/message/compose?to=%2Fr%2F${subreddit}&message=${encodeURIComponent(permalink)}`;
|
||||
|
||||
const templateData: any = {
|
||||
kind: activity instanceof Submission ? 'submission' : 'comment',
|
||||
// @ts-ignore
|
||||
author: getActivityAuthorName(await activity.author),
|
||||
votes: activity.score,
|
||||
age: dayjs.duration(dayjs().diff(dayjs.unix(activity.created))).humanize(),
|
||||
permalink,
|
||||
id: activity.name,
|
||||
subreddit,
|
||||
...conditional
|
||||
}
|
||||
if (template.includes('{{item.notes') && usernotes !== undefined) {
|
||||
// we need to get notes
|
||||
const notesData = await usernotes.getUserNotes(activity.author);
|
||||
// return usable notes data with some stats
|
||||
const current = notesData.length > 0 ? notesData[notesData.length - 1] : undefined;
|
||||
// group by type
|
||||
const grouped = notesData.reduce((acc: any, x) => {
|
||||
const {[x.noteType]: nt = []} = acc;
|
||||
return Object.assign(acc, {[x.noteType]: nt.concat(x)});
|
||||
}, {});
|
||||
templateData.notes = {
|
||||
data: notesData,
|
||||
current,
|
||||
...grouped,
|
||||
};
|
||||
}
|
||||
if (activity instanceof Submission) {
|
||||
templateData.url = activity.url;
|
||||
templateData.title = activity.title;
|
||||
templateData.shortTitle = shortTitleTruncate(activity.title);
|
||||
} else {
|
||||
templateData.title = renderContentCommentTruncate(activity.body);
|
||||
templateData.shortTitle = shortTitleTruncate(activity.body);
|
||||
}
|
||||
|
||||
view.item = templateData;
|
||||
}
|
||||
if (template.includes('{{item.notes')) {
|
||||
// we need to get notes
|
||||
const notesData = await usernotes.getUserNotes(data.author);
|
||||
// return usable notes data with some stats
|
||||
const current = notesData.length > 0 ? notesData[notesData.length - 1] : undefined;
|
||||
// group by type
|
||||
const grouped = notesData.reduce((acc: any, x) => {
|
||||
const {[x.noteType]: nt = []} = acc;
|
||||
return Object.assign(acc, {[x.noteType]: nt.concat(x)});
|
||||
}, {});
|
||||
templateData.notes = {
|
||||
data: notesData,
|
||||
current,
|
||||
...grouped,
|
||||
|
||||
|
||||
if(ruleResults !== undefined) {
|
||||
view = {
|
||||
...view,
|
||||
...parseRuleResultForTemplate(ruleResults)
|
||||
}
|
||||
}
|
||||
|
||||
if(actionResults !== undefined) {
|
||||
view = {
|
||||
...view,
|
||||
...parseActionResultForTemplate(actionResults)
|
||||
}
|
||||
}
|
||||
|
||||
const rendered = Mustache.render(template, view) as string;
|
||||
return he.decode(rendered);
|
||||
}
|
||||
|
||||
export const parseActionResultForTemplate = (actionResults: ActionResultEntity[] = []) => {
|
||||
// normalize rule names and map context data
|
||||
// NOTE: we are relying on users to use unique names for action. If they don't only the last action run of kind X will have its results here
|
||||
const normalizedActionResults = actionResults.reduce((acc: object, actionResult) => {
|
||||
const {
|
||||
success,
|
||||
data:{
|
||||
...restData
|
||||
} = {},
|
||||
result,
|
||||
} = actionResult;
|
||||
let name = actionResult.premise.name;
|
||||
const kind = actionResult.premise.kind.name;
|
||||
if(name === undefined || name === null) {
|
||||
name = kind;
|
||||
}
|
||||
let formattedData: any = {};
|
||||
// remove all non-alphanumeric characters (spaces, dashes, underscore) and set to lowercase
|
||||
// we will set this as the rule property name to make it easy to access results from mustache template
|
||||
const normalName = normalizeName(name);
|
||||
return {
|
||||
...acc, [normalName]: {
|
||||
kind,
|
||||
success,
|
||||
result,
|
||||
...restData,
|
||||
...formattedData,
|
||||
}
|
||||
};
|
||||
}
|
||||
if (data instanceof Submission) {
|
||||
templateData.url = data.url;
|
||||
templateData.title = data.title;
|
||||
templateData.shortTitle = shortTitleTruncate(data.title);
|
||||
} else {
|
||||
templateData.title = renderContentCommentTruncate(data.body);
|
||||
templateData.shortTitle = shortTitleTruncate(data.body);
|
||||
}
|
||||
}, {});
|
||||
|
||||
return {
|
||||
actionSummary: parseResultsToMarkdownSummary(actionResults),
|
||||
actions: normalizedActionResults
|
||||
};
|
||||
}
|
||||
|
||||
export const parseRuleResultForTemplate = (ruleResults: RuleResultEntity[] = []) => {
|
||||
|
||||
// normalize rule names and map context data
|
||||
// NOTE: we are relying on users to use unique names for rules. If they don't only the last rule run of kind X will have its results here
|
||||
const normalizedRuleResults = ruleResults.reduce((acc: object, ruleResult) => {
|
||||
const {
|
||||
//name,
|
||||
triggered,
|
||||
data = {},
|
||||
data:{
|
||||
subredditBreakdown,
|
||||
...restData
|
||||
} = {},
|
||||
result,
|
||||
// premise: {
|
||||
// kind
|
||||
// }
|
||||
} = ruleResult;
|
||||
let name = ruleResult.premise.name;
|
||||
const kind = ruleResult.premise.kind.name;
|
||||
if(name === undefined || name === null) {
|
||||
name = kind;
|
||||
}
|
||||
let formattedData: any = {};
|
||||
if (subredditBreakdown !== undefined) {
|
||||
// format breakdown for markdown
|
||||
if (Array.isArray(subredditBreakdown)) {
|
||||
const bdArr = subredditBreakdown as SubredditActivityBreakdown[];
|
||||
formattedData.subredditBreakdownFormatted = formatSubredditBreakdownAsMarkdownList(bdArr);
|
||||
} else {
|
||||
const bd = subredditBreakdown as SubredditActivityBreakdownByType;
|
||||
|
||||
// default to total
|
||||
formattedData.subredditBreakdownFormatted = formatSubredditBreakdownAsMarkdownList(bd.total);
|
||||
|
||||
const formatted = Object.entries((bd)).reduce((acc: { [key: string]: string }, curr) => {
|
||||
const [name, breakdownData] = curr;
|
||||
acc[`${name}Formatted`] = formatSubredditBreakdownAsMarkdownList(breakdownData);
|
||||
return acc;
|
||||
}, {});
|
||||
formattedData.subredditBreakdown = {...bd, ...formatted};
|
||||
}
|
||||
}
|
||||
// remove all non-alphanumeric characters (spaces, dashes, underscore) and set to lowercase
|
||||
// we will set this as the rule property name to make it easy to access results from mustache template
|
||||
const normalName = normalizeName(name);
|
||||
@@ -194,14 +312,16 @@ export const renderContent = async (template: string, data: (Submission | Commen
|
||||
kind,
|
||||
triggered,
|
||||
result,
|
||||
...data,
|
||||
...restData,
|
||||
...formattedData,
|
||||
}
|
||||
};
|
||||
}, {});
|
||||
|
||||
const view = {item: templateData, ruleSummary: parseRuleResultsToMarkdownSummary(ruleResults), rules: normalizedRuleResults};
|
||||
const rendered = Mustache.render(template, view) as string;
|
||||
return he.decode(rendered);
|
||||
return {
|
||||
ruleSummary: parseResultsToMarkdownSummary(ruleResults),
|
||||
rules: normalizedRuleResults
|
||||
};
|
||||
}
|
||||
|
||||
export interface ItemContent {
|
||||
@@ -391,3 +511,58 @@ export const getAuthorHistoryAPIOptions = (val: any) => {
|
||||
|
||||
return opts;
|
||||
}
|
||||
|
||||
export const getSubredditBreakdown = (activities: SnoowrapActivity[] = []): SubredditActivityBreakdown[] => {
|
||||
if(activities.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const total = activities.length;
|
||||
|
||||
const countBd = activities.reduce((acc: { [key: string]: number }, curr) => {
|
||||
const subName = curr.subreddit.display_name;
|
||||
if (acc[subName] === undefined) {
|
||||
acc[subName] = 0;
|
||||
}
|
||||
acc[subName]++;
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const breakdown: SubredditActivityBreakdown[] = Object.entries(countBd).reduce((acc, curr) => {
|
||||
const [name, count] = curr;
|
||||
return acc.concat(
|
||||
{
|
||||
name,
|
||||
count,
|
||||
percent: Number.parseFloat(formatNumber((count / total) * 100))
|
||||
}
|
||||
);
|
||||
}, ([] as SubredditActivityBreakdown[]));
|
||||
|
||||
return breakdown;
|
||||
}
|
||||
|
||||
export const getSubredditBreakdownByActivityType = (activities: SnoowrapActivity[]): SubredditActivityBreakdownByType => {
|
||||
|
||||
return {
|
||||
total: getSubredditBreakdown(activities),
|
||||
submission: getSubredditBreakdown(activities.filter(x => x instanceof Submission)),
|
||||
comment: getSubredditBreakdown(activities.filter(x => x instanceof Comment)),
|
||||
}
|
||||
}
|
||||
|
||||
export const formatSubredditBreakdownAsMarkdownList = (data: SubredditActivityBreakdown[] = []): string => {
|
||||
if(data.length === 0) {
|
||||
return '';
|
||||
}
|
||||
data.sort((a, b) => b.count - a.count);
|
||||
|
||||
const bd = data.map(x => {
|
||||
const entity = parseRedditEntity(x.name);
|
||||
const prefixedName = entity.type === 'subreddit' ? `r/${entity.name}` : `u/${entity.name}`;
|
||||
return `* ${prefixedName} - ${x.count} (${x.percent}%)`
|
||||
}).join('\n');
|
||||
|
||||
return `${bd}\n`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
BotInstance,
|
||||
BotInstanceResponse,
|
||||
BotInstanceResponse, BotSubredditInviteResponse,
|
||||
CMInstanceInterface,
|
||||
ManagerResponse,
|
||||
NormalizedManagerResponse
|
||||
@@ -15,6 +15,7 @@ export class ClientBotInstance implements BotInstance {
|
||||
managers: NormalizedManagerResponse[];
|
||||
nanny?: string | undefined;
|
||||
running: boolean;
|
||||
invites: BotSubredditInviteResponse[]
|
||||
|
||||
constructor(data: BotInstanceResponse, instance: CMInstanceInterface) {
|
||||
this.instance = instance;
|
||||
@@ -24,6 +25,7 @@ export class ClientBotInstance implements BotInstance {
|
||||
this.managers = data.managers.map(x => ({...x, subredditNormal: parseRedditEntity(x.subreddit).name}));
|
||||
this.nanny = data.nanny;
|
||||
this.running = data.running;
|
||||
this.invites = data.invites === undefined || data.invites === null ? [] : data.invites;
|
||||
}
|
||||
|
||||
getManagerNames(): string[] {
|
||||
@@ -56,6 +58,14 @@ export class ClientBotInstance implements BotInstance {
|
||||
return this.getAccessibleSubreddits(user, subreddits).includes(parseRedditEntity(subreddit).name);
|
||||
}
|
||||
|
||||
getInvites() {
|
||||
return this.invites;
|
||||
}
|
||||
|
||||
getInvite(val: string) {
|
||||
return this.invites.find(x => x.id === val);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ClientBotInstance;
|
||||
|
||||
@@ -33,7 +33,7 @@ import tcpUsed from "tcp-port-used";
|
||||
import http from "http";
|
||||
import jwt from 'jsonwebtoken';
|
||||
import {Server as SocketServer} from "socket.io";
|
||||
import got from 'got';
|
||||
import got, {HTTPError} from 'got';
|
||||
import sharedSession from "express-socket.io-session";
|
||||
import dayjs from "dayjs";
|
||||
import httpProxy from 'http-proxy';
|
||||
@@ -56,7 +56,13 @@ import {MigrationService} from "../../Common/MigrationService";
|
||||
import {RuleResultEntity} from "../../Common/Entities/RuleResultEntity";
|
||||
import {RuleSetResultEntity} from "../../Common/Entities/RuleSetResultEntity";
|
||||
import { PaginationAwareObject } from "../Common/util";
|
||||
import {BotInstance, BotStatusResponse, CMInstanceInterface, InviteData} from "../Common/interfaces";
|
||||
import {
|
||||
BotInstance,
|
||||
BotStatusResponse,
|
||||
BotSubredditInviteResponse,
|
||||
CMInstanceInterface, HeartbeatResponse,
|
||||
InviteData, SubredditInviteDataPersisted
|
||||
} from "../Common/interfaces";
|
||||
import {open} from "fs/promises";
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
@@ -593,7 +599,18 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
next();
|
||||
}
|
||||
|
||||
app.getAsync('/auth/helper', helperAuthed, instanceWithPermissions, instancesViewData, (req, res) => {
|
||||
const initHeartbeat = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
if(!init) {
|
||||
for(const c of clients) {
|
||||
await refreshClient(c);
|
||||
}
|
||||
init = true;
|
||||
loopHeartbeat();
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
app.getAsync('/auth/helper', initHeartbeat, helperAuthed, instanceWithPermissions, instancesViewData, (req, res) => {
|
||||
return res.render('helper', {
|
||||
redirectUri: clientCredentials.redirectUri,
|
||||
clientId: clientCredentials.clientId,
|
||||
@@ -604,7 +621,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.getAsync('/auth/invite/:inviteId', async (req, res) => {
|
||||
app.getAsync('/auth/invite/:inviteId', initHeartbeat, async (req, res) => {
|
||||
const {inviteId} = req.params;
|
||||
|
||||
if (inviteId === undefined) {
|
||||
@@ -699,7 +716,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync('/auth/init/:inviteId', async (req: express.Request, res: express.Response) => {
|
||||
app.getAsync('/auth/init/:inviteId', initHeartbeat, async (req: express.Request, res: express.Response) => {
|
||||
const { inviteId } = req.params;
|
||||
if(inviteId === undefined) {
|
||||
return res.render('error', {error: '`invite` param is missing from URL'});
|
||||
@@ -822,6 +839,8 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
next();
|
||||
}
|
||||
|
||||
|
||||
|
||||
// const authenticatedRouter = Router();
|
||||
// authenticatedRouter.use([ensureAuthenticated, defaultSession]);
|
||||
// app.use(authenticatedRouter);
|
||||
@@ -837,7 +856,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
// logger.debug(`Got proxy response: ${res.statusCode} for ${req.url}`);
|
||||
// });
|
||||
|
||||
app.useAsync('/api/', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(false), createUserToken], (req: express.Request, res: express.Response) => {
|
||||
app.useAsync('/api/', [ensureAuthenticatedApi, initHeartbeat, defaultSession, instanceWithPermissions, botWithPermissions(false), createUserToken], (req: express.Request, res: express.Response) => {
|
||||
req.headers.Authorization = `Bearer ${req.token}`
|
||||
|
||||
const instance = req.instance as CMInstanceInterface;
|
||||
@@ -889,17 +908,6 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
next();
|
||||
}*/
|
||||
|
||||
const initHeartbeat = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
if(!init) {
|
||||
for(const c of clients) {
|
||||
await refreshClient(c);
|
||||
}
|
||||
init = true;
|
||||
loopHeartbeat();
|
||||
}
|
||||
next();
|
||||
};
|
||||
|
||||
const redirectBotsNotAuthed = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
if(cmInstances.length === 1 && cmInstances[0].error === 'Missing credentials: refreshToken, accessToken') {
|
||||
// assuming user is doing first-time setup and this is the default localhost bot
|
||||
@@ -1029,6 +1037,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
...req.instancesViewData,
|
||||
bots: resp.bots,
|
||||
now: dayjs().add(1, 'minute').format('YYYY-MM-DDTHH:mm'),
|
||||
defaultExpire: dayjs().add(1, 'day').format('YYYY-MM-DDTHH:mm'),
|
||||
botId: (req.instance as CMInstance).getName(),
|
||||
isOperator: isOp,
|
||||
system: isOp ? {
|
||||
@@ -1045,8 +1054,139 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.getAsync('/bot/invites', defaultSession, async (req: express.Request, res: express.Response) => {
|
||||
res.render('modInvites', {
|
||||
app.getAsync('/bot/invites/subreddit/:inviteId', initHeartbeat, ensureAuthenticated, defaultSession, async (req: express.Request, res: express.Response) => {
|
||||
|
||||
const {inviteId} = req.params;
|
||||
|
||||
if (inviteId === undefined) {
|
||||
return res.render('error', {error: '`invite` param is missing from URL'});
|
||||
}
|
||||
|
||||
let validInstance: CMInstance | undefined = undefined;
|
||||
let validInvite: BotSubredditInviteResponse | undefined = undefined;
|
||||
let validBot: BotInstance | undefined = undefined;
|
||||
for(const instance of cmInstances) {
|
||||
for(const bot of instance.bots) {
|
||||
validInvite = bot.getInvite(inviteId);
|
||||
if(validInvite !== undefined) {
|
||||
validInstance = instance;
|
||||
validBot = bot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(validInvite === undefined) {
|
||||
// try refreshing clients first
|
||||
await refreshClients(true);
|
||||
}
|
||||
|
||||
for(const instance of cmInstances) {
|
||||
for(const bot of instance.bots) {
|
||||
validInvite = bot.getInvite(inviteId);
|
||||
if(validInvite !== undefined) {
|
||||
validInstance = instance;
|
||||
validBot = bot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(validInvite === undefined || validInstance === undefined || validBot === undefined) {
|
||||
return res.render('error', {error: 'Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.'});
|
||||
}
|
||||
|
||||
const user = req.user as Express.User;
|
||||
|
||||
// @ts-ignore
|
||||
if(!user.subreddits.some(x => x.toLowerCase() === validInvite.subreddit.toLowerCase())) {
|
||||
return res.render('error', {error: 'Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.'});
|
||||
}
|
||||
|
||||
try {
|
||||
const invite = await got.get(`${validInstance.normalUrl}/bot/invite/${validInvite.id}?bot=${validBot.botName}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${validInstance.getToken()}`,
|
||||
}
|
||||
}).json() as SubredditInviteDataPersisted;
|
||||
|
||||
const {guests, ...rest} = invite;
|
||||
const guestStr = guests !== undefined && guests !== null && guests.length > 0 ? guests.join(',') : '';
|
||||
|
||||
return res.render('subredditOnboard/onboard', {
|
||||
invite: {...rest, guests: guestStr},
|
||||
bot: validBot.botName,
|
||||
title: `Subreddit Onboarding`,
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(err);
|
||||
return res.render('error', {error: `Error occurred while retriving invite data: ${err.message}`});
|
||||
}
|
||||
});
|
||||
|
||||
app.postAsync('/bot/invites/subreddit/:inviteId', ensureAuthenticated, defaultSession, async (req: express.Request, res: express.Response) => {
|
||||
|
||||
const {inviteId} = req.params;
|
||||
|
||||
if (inviteId === undefined) {
|
||||
return res.status(400).send('`invite` param is missing from URL')
|
||||
}
|
||||
|
||||
let validInstance: CMInstance | undefined = undefined;
|
||||
let validInvite: BotSubredditInviteResponse | undefined = undefined;
|
||||
let validBot: BotInstance | undefined = undefined;
|
||||
for(const instance of cmInstances) {
|
||||
for(const bot of instance.bots) {
|
||||
validInvite = bot.getInvite(inviteId);
|
||||
if(validInvite !== undefined) {
|
||||
validInstance = instance;
|
||||
validBot = bot;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(validInvite === undefined || validInstance === undefined || validBot === undefined) {
|
||||
return res.status(400).send('Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.')
|
||||
}
|
||||
|
||||
const user = req.user as Express.User;
|
||||
|
||||
// @ts-ignore
|
||||
if(!user.subreddits.some(x => x.toLowerCase() === validInvite.subreddit.toLowerCase())) {
|
||||
return res.status(400).send('Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.')
|
||||
}
|
||||
|
||||
try {
|
||||
await got.post(`${validInstance.normalUrl}/bot/invite/${validInvite.id}?bot=${validBot.botName}`, {
|
||||
json: req.body,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${validInstance.getToken()}`,
|
||||
}
|
||||
})
|
||||
|
||||
return res.status(200);
|
||||
|
||||
} catch (err: any) {
|
||||
logger.error(err);
|
||||
res.status(500)
|
||||
let msg = err.message;
|
||||
if(err instanceof HTTPError && typeof err.response.body === 'string') {
|
||||
msg = err.response.body
|
||||
}
|
||||
return res.send(msg);
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync('/bot/invites/subreddit', initHeartbeat, ensureAuthenticated, defaultSession, instanceWithPermissions, botWithPermissions(true), async (req: express.Request, res: express.Response) => {
|
||||
res.render('subredditOnboard/helper', {
|
||||
title: `Create Subreddit Invite`,
|
||||
});
|
||||
});
|
||||
|
||||
app.getAsync('/bot/invites', initHeartbeat, ensureAuthenticated, defaultSession, async (req: express.Request, res: express.Response) => {
|
||||
res.render('subredditOnboard/manager', {
|
||||
title: `Pending Moderation Invites`,
|
||||
});
|
||||
});
|
||||
@@ -1060,7 +1200,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.getAsync('/guest', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(true)], async (req: express.Request, res: express.Response) => {
|
||||
app.getAsync('/guest', [ensureAuthenticatedApi, initHeartbeat, defaultSession, instanceWithPermissions, botWithPermissions(true)], async (req: express.Request, res: express.Response) => {
|
||||
const {subreddit} = req.query as any;
|
||||
return res.status(req.user?.isSubredditGuest(req.bot, subreddit) ? 200 : 403).send();
|
||||
});
|
||||
@@ -1106,7 +1246,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
return res.send();
|
||||
});
|
||||
|
||||
app.getAsync('/events', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(true), createUserToken], async (req: express.Request, res: express.Response) => {
|
||||
app.getAsync('/events', [ensureAuthenticatedApi, initHeartbeat, defaultSession, instanceWithPermissions, botWithPermissions(true), createUserToken], async (req: express.Request, res: express.Response) => {
|
||||
const {subreddit, page = 1, permalink, related, author} = req.query as any;
|
||||
const resp = await got.get(`${(req.instance as CMInstanceInterface).normalUrl}/events`, {
|
||||
headers: {
|
||||
@@ -1462,27 +1602,6 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
}
|
||||
emitter.on('log', botWebLogListener);
|
||||
socketListeners.set(socket.id, [...(socketListeners.get(socket.id) || []), botWebLogListener]);
|
||||
|
||||
// only setup streams if the user can actually access them (not just a web operator)
|
||||
if(session.authBotId !== undefined) {
|
||||
// streaming stats from client
|
||||
const newStreams: (AbortController | NodeJS.Timeout)[] = [];
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const resp = await got.get(`${bot.normalUrl}/stats`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${createToken(bot, user)}`,
|
||||
}
|
||||
}).json() as object;
|
||||
io.to(session.id).emit('opStats', resp);
|
||||
} catch (err: any) {
|
||||
bot.logger.error(new ErrorWithCause('Could not retrieve stats', {cause: err}));
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 5000);
|
||||
newStreams.push(interval);
|
||||
sockStreams.set(socket.id, newStreams);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1495,14 +1614,18 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
|
||||
const loopHeartbeat = async () => {
|
||||
while(true) {
|
||||
for(const c of clients) {
|
||||
await refreshClient(c);
|
||||
}
|
||||
await refreshClients();
|
||||
// sleep for 10 seconds then do heartbeat check again
|
||||
await sleep(10000);
|
||||
}
|
||||
}
|
||||
|
||||
const refreshClients = async (force = false) => {
|
||||
for(const c of clients) {
|
||||
await refreshClient(c, force);
|
||||
}
|
||||
}
|
||||
|
||||
const addBot = async (inviteId: string, botPayload: any) => {
|
||||
|
||||
const cmInstance = cmInstances.find(x => x.invites.includes(inviteId));
|
||||
|
||||
@@ -16,24 +16,24 @@ class ServerUser extends CMUser<App, Bot, Manager> {
|
||||
}
|
||||
|
||||
canAccessInstance(val: App): boolean {
|
||||
return this.isOperator || val.bots.filter(x => x.canUserAccessBot(this.name, this.subreddits)).length > 0;
|
||||
return this.isOperator || this.machine || val.bots.filter(x => x.canUserAccessBot(this.name, this.subreddits)).length > 0;
|
||||
}
|
||||
|
||||
canAccessBot(val: Bot): boolean {
|
||||
return this.isOperator || val.canUserAccessBot(this.name, this.subreddits);
|
||||
return this.isOperator || this.machine || val.canUserAccessBot(this.name, this.subreddits);
|
||||
}
|
||||
|
||||
accessibleBots(bots: Bot[]): Bot[] {
|
||||
return this.isOperator ? bots : bots.filter(x => x.canUserAccessBot(this.name, this.subreddits));
|
||||
return (this.isOperator || this.machine) ? bots : bots.filter(x => x.canUserAccessBot(this.name, this.subreddits));
|
||||
}
|
||||
|
||||
canAccessSubreddit(val: Bot, name: string): boolean {
|
||||
const normalName = parseRedditEntity(name).name;
|
||||
return this.isOperator || this.accessibleSubreddits(val).some(x => x.toNormalizedManager().subredditNormal === normalName);
|
||||
return this.isOperator || this.machine || this.accessibleSubreddits(val).some(x => x.toNormalizedManager().subredditNormal === normalName);
|
||||
}
|
||||
|
||||
accessibleSubreddits(bot: Bot): Manager[] {
|
||||
if(this.isOperator) {
|
||||
if(this.isOperator || this.machine) {
|
||||
return bot.subManagers;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {BotConnection, LogInfo, ManagerStats} from "../../Common/interfaces";
|
||||
import {Guest, GuestAll} from "../../Common/Entities/Guest/GuestInterfaces";
|
||||
import {URL} from "url";
|
||||
import {Dayjs} from "dayjs";
|
||||
import {Subreddit} from "snoowrap/dist/objects";
|
||||
|
||||
export interface BotStats {
|
||||
startedAtHuman: string,
|
||||
@@ -90,6 +91,10 @@ export interface NormalizedManagerResponse extends ManagerResponse {
|
||||
subredditNormal: string
|
||||
}
|
||||
|
||||
export interface BotSubredditInviteResponse {
|
||||
subreddit: string
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface BotInstanceResponse {
|
||||
botName: string
|
||||
@@ -98,6 +103,12 @@ export interface BotInstanceResponse {
|
||||
managers: ManagerResponse[]
|
||||
nanny?: string
|
||||
running: boolean
|
||||
invites: BotSubredditInviteResponse[]
|
||||
}
|
||||
|
||||
export interface SubredditOnboardingReadiness {
|
||||
hasManager: boolean
|
||||
isMod: boolean
|
||||
}
|
||||
|
||||
export interface BotInstanceFunctions {
|
||||
@@ -108,6 +119,7 @@ export interface BotInstanceFunctions {
|
||||
getGuestSubreddits: (user: string) => string[]
|
||||
canUserAccessBot: (user: string, subreddits: string[]) => boolean
|
||||
canUserAccessSubreddit: (subreddit: string, user: string, subreddits: string[]) => boolean
|
||||
getInvite(val: string): BotSubredditInviteResponse | undefined
|
||||
}
|
||||
|
||||
export interface BotInstance extends BotInstanceResponse, BotInstanceFunctions {
|
||||
@@ -162,3 +174,11 @@ export interface SubredditInviteData {
|
||||
initialConfig?: string
|
||||
expiresAt?: number | Dayjs
|
||||
}
|
||||
|
||||
export interface HydratedSubredditInviteData extends Omit<SubredditInviteData, 'subreddit'>{
|
||||
subreddit: string | Subreddit
|
||||
}
|
||||
|
||||
export interface SubredditInviteDataPersisted extends SubredditInviteData {
|
||||
id: string
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export const heartbeat = (opData: OperatorData) => {
|
||||
guests: y.managerEntity.getGuests().map(x => guestEntityToApiGuest(x)),
|
||||
})),
|
||||
running: x.running,
|
||||
invites: x.getSubredditInvites().map(y => ({subreddit: y.subreddit, id: y.id}))
|
||||
})),
|
||||
operators: opData.name,
|
||||
operatorDisplay: opData.display,
|
||||
|
||||
@@ -237,12 +237,7 @@ const saveGuestWikiEdit = async (req: Request, res: Response) => {
|
||||
const {location, data, reason = 'Updated through CM Web', create = false} = req.body as any;
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const wiki = await req.manager?.subreddit.getWikiPage(location) as WikiPage;
|
||||
await wiki.edit({
|
||||
text: data,
|
||||
reason: `${reason} by Guest Mod ${req.user?.name}`,
|
||||
});
|
||||
await req.manager?.writeConfig(data, `${reason} by Guest Mod ${req.user?.name}`)
|
||||
} catch (err: any) {
|
||||
res.status(500);
|
||||
return res.send(err.message);
|
||||
|
||||
@@ -4,17 +4,61 @@ import {CMError} from "../../../../../Utils/Errors";
|
||||
|
||||
const getSubredditInvites = async (req: Request, res: Response) => {
|
||||
|
||||
return res.json(await req.serverBot.cacheManager.getPendingSubredditInvites());
|
||||
return res.json(await req.serverBot.getSubredditInvites());
|
||||
};
|
||||
export const getSubredditInvitesRoute = [authUserCheck(), botRoute(), getSubredditInvites];
|
||||
|
||||
const getSubredditInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {id} = req.params;
|
||||
const invite = await req.serverBot.getInvite(id);
|
||||
if(invite !== undefined) {
|
||||
const {bot, ...inviteRest} = invite;
|
||||
const readiness = req.serverBot.getOnboardingReadiness(invite);
|
||||
return res.json({...inviteRest, ...readiness});
|
||||
}
|
||||
return res.status(404);
|
||||
};
|
||||
export const getSubredditInviteRoute = [authUserCheck(['operator', 'machine']), botRoute(), getSubredditInvite];
|
||||
|
||||
const acceptSubredditInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {id} = req.params;
|
||||
const invite = await req.serverBot.getInvite(id);
|
||||
if(invite !== undefined) {
|
||||
const {initialConfig, guests} = req.body as any;
|
||||
invite.initialConfig = initialConfig;
|
||||
invite.guests = guests;
|
||||
|
||||
try {
|
||||
await req.serverBot.finishOnboarding(invite);
|
||||
return res.status(200);
|
||||
} catch(e: any) {
|
||||
const errorParts = [e.message];
|
||||
if(e instanceof CMError && e.cause !== undefined) {
|
||||
errorParts.push(e.cause?.message);
|
||||
}
|
||||
res.status(500)
|
||||
return res.send(e.message);
|
||||
}
|
||||
}
|
||||
return res.status(404);
|
||||
};
|
||||
export const acceptSubredditInviteRoute = [authUserCheck(['operator', 'machine']), botRoute(), acceptSubredditInvite];
|
||||
|
||||
const addSubredditInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {subreddit} = req.body as any;
|
||||
const {subreddit, initialConfig, guests} = req.body as any;
|
||||
if (subreddit === undefined || subreddit === null || subreddit === '') {
|
||||
return res.status(400).send('subreddit must be defined');
|
||||
}
|
||||
try {
|
||||
await req.serverBot.cacheManager.addPendingSubredditInvite(subreddit);
|
||||
const invite = await req.serverBot.addSubredditInvite({
|
||||
subreddit,
|
||||
initialConfig,
|
||||
guests,
|
||||
});
|
||||
return res.status(200).send(invite.id);
|
||||
} catch (e: any) {
|
||||
if (e instanceof CMError) {
|
||||
req.logger.warn(e);
|
||||
@@ -24,16 +68,15 @@ const addSubredditInvite = async (req: Request, res: Response) => {
|
||||
return res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
return res.status(200).send();
|
||||
};
|
||||
export const addSubredditInviteRoute = [authUserCheck(), botRoute(), addSubredditInvite];
|
||||
const deleteSubredditInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {subreddit} = req.query as any;
|
||||
const {subreddit, id} = req.query as any;
|
||||
if (subreddit === undefined || subreddit === null || subreddit === '') {
|
||||
return res.status(400).send('subreddit must be defined');
|
||||
}
|
||||
await req.serverBot.cacheManager.deletePendingSubredditInvite(subreddit);
|
||||
await req.serverBot.deleteSubredditInvite(subreddit);
|
||||
return res.status(200).send();
|
||||
};
|
||||
export const deleteSubredditInviteRoute = [authUserCheck(), botRoute(), deleteSubredditInvite];
|
||||
|
||||
@@ -86,6 +86,24 @@ const generateDeltaResponse = (data: Record<string, any>, hash: string, response
|
||||
// delta[k] = {new: newGuestItems, removed: removedGuestItems};
|
||||
delta[k] = v;
|
||||
break;
|
||||
case 'subreddits':
|
||||
// only used by opStats!
|
||||
const refSubs = reference[k].map((x: any) => `${x.name}-${x.indicator}`);
|
||||
const lastestSubs = v.map((x: any) => `${x.name}-${x.indicator}`);
|
||||
|
||||
if(symmetricalDifference(refSubs, lastestSubs).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const changedSubs = v.reduce((acc: any[], curr: any) => {
|
||||
if(!reference[k].some((x: any) => x.name === curr.name && x.indicator === curr.indicator)) {
|
||||
acc.push(curr);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
delta[k] = changedSubs;
|
||||
break
|
||||
default:
|
||||
if(!deepEqual(v, reference[k])) {
|
||||
if(v !== null && typeof v === 'object' && reference[k] !== null && typeof reference[k] === 'object') {
|
||||
@@ -104,6 +122,67 @@ const generateDeltaResponse = (data: Record<string, any>, hash: string, response
|
||||
return resp;
|
||||
}
|
||||
|
||||
export const opStatResponse = () => {
|
||||
const middleware = [
|
||||
authUserCheck(),
|
||||
botRoute(false)
|
||||
];
|
||||
|
||||
const response = async(req: Request, res: Response) =>
|
||||
{
|
||||
const responseType = req.query.type === 'delta' ? 'delta' : 'full';
|
||||
|
||||
let bots: Bot[] = [];
|
||||
if(req.serverBot !== undefined) {
|
||||
bots = [req.serverBot];
|
||||
} else if(req.user !== undefined) {
|
||||
bots = req.user.accessibleBots(req.botApp.bots);
|
||||
}
|
||||
const resp = [];
|
||||
let index = 1;
|
||||
for(const b of bots) {
|
||||
resp.push({name: b.botName ?? `Bot ${index}`, data: {
|
||||
status: b.running ? 'RUNNING' : 'NOT RUNNING',
|
||||
indicator: b.running ? 'green' : 'red',
|
||||
running: b.running,
|
||||
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
error: b.error,
|
||||
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
|
||||
let indicator;
|
||||
if (manager.managerState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
|
||||
indicator = 'green';
|
||||
} else if (manager.managerState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
|
||||
indicator = 'red';
|
||||
} else {
|
||||
indicator = 'yellow';
|
||||
}
|
||||
return {
|
||||
name: manager.displayLabel,
|
||||
indicator,
|
||||
};
|
||||
}),
|
||||
}});
|
||||
index++;
|
||||
}
|
||||
|
||||
const deltaResp = [];
|
||||
for(const bResp of resp) {
|
||||
const hash = `${req.user?.name}-opstats-${bResp.name}`;
|
||||
const respData = generateDeltaResponse(bResp.data, hash, responseType);
|
||||
if(Object.keys(respData).length !== 0) {
|
||||
deltaResp.push({data: respData, name: bResp.name});
|
||||
}
|
||||
}
|
||||
|
||||
if(deltaResp.length === 0) {
|
||||
return res.status(304).send();
|
||||
}
|
||||
return res.json(deltaResp);
|
||||
}
|
||||
|
||||
return [...middleware, response];
|
||||
}
|
||||
|
||||
const liveStats = () => {
|
||||
const middleware = [
|
||||
authUserCheck(),
|
||||
|
||||
@@ -13,7 +13,7 @@ import http from "http";
|
||||
import {heartbeat} from "./routes/authenticated/applicationRoutes";
|
||||
import logs from "./routes/authenticated/user/logs";
|
||||
import status from './routes/authenticated/user/status';
|
||||
import liveStats from './routes/authenticated/user/liveStats';
|
||||
import liveStats, {opStatResponse} from './routes/authenticated/user/liveStats';
|
||||
import {
|
||||
actionedEventsRoute,
|
||||
actionRoute, addGuestModRoute,
|
||||
@@ -36,10 +36,12 @@ import { sleep } from '../../util';
|
||||
import {Invokee} from "../../Common/Infrastructure/Atomic";
|
||||
import {Point} from "@influxdata/influxdb-client";
|
||||
import {
|
||||
acceptSubredditInviteRoute,
|
||||
addBotInviteRoute,
|
||||
addSubredditInviteRoute,
|
||||
deleteSubredditInviteRoute,
|
||||
getBotInviteRoute,
|
||||
getSubredditInviteRoute,
|
||||
getSubredditInvitesRoute
|
||||
} from "./routes/authenticated/user/invites";
|
||||
|
||||
@@ -161,41 +163,8 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
|
||||
|
||||
server.getAsync('/logs', ...logs());
|
||||
|
||||
server.getAsync('/stats', [authUserCheck(), botRoute(false)], async (req: Request, res: Response) => {
|
||||
let bots: Bot[] = [];
|
||||
if(req.serverBot !== undefined) {
|
||||
bots = [req.serverBot];
|
||||
} else if(req.user !== undefined) {
|
||||
bots = req.user.accessibleBots(req.botApp.bots);
|
||||
}
|
||||
const resp = [];
|
||||
let index = 1;
|
||||
for(const b of bots) {
|
||||
resp.push({name: b.botName ?? `Bot ${index}`, data: {
|
||||
status: b.running ? 'RUNNING' : 'NOT RUNNING',
|
||||
indicator: b.running ? 'green' : 'red',
|
||||
running: b.running,
|
||||
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
error: b.error,
|
||||
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
|
||||
let indicator;
|
||||
if (manager.managerState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
|
||||
indicator = 'green';
|
||||
} else if (manager.managerState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
|
||||
indicator = 'red';
|
||||
} else {
|
||||
indicator = 'yellow';
|
||||
}
|
||||
return {
|
||||
name: manager.displayLabel,
|
||||
indicator,
|
||||
};
|
||||
}),
|
||||
}});
|
||||
index++;
|
||||
}
|
||||
return res.json(resp);
|
||||
});
|
||||
server.getAsync('/stats', ...opStatResponse());
|
||||
|
||||
const passLogs = async (req: Request, res: Response, next: Function) => {
|
||||
// @ts-ignore
|
||||
req.sysLogs = sysLogs;
|
||||
@@ -223,6 +192,10 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
|
||||
|
||||
server.getAsync('/bot/invite', ...getSubredditInvitesRoute);
|
||||
|
||||
server.getAsync('/bot/invite/:id', ...getSubredditInviteRoute);
|
||||
|
||||
server.postAsync('/bot/invite/:id', ...acceptSubredditInviteRoute);
|
||||
|
||||
server.postAsync('/bot/invite', ...addSubredditInviteRoute);
|
||||
|
||||
server.deleteAsync('/bot/invite', ...deleteSubredditInviteRoute);
|
||||
|
||||
@@ -190,3 +190,8 @@ li > ul {
|
||||
.introjs-tooltip-title,.introjs-tooltiptext {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.guestAdd {
|
||||
border-top: 1px solid white;
|
||||
padding-top: 0.5em;
|
||||
}
|
||||
|
||||
BIN
src/Web/assets/public/logo.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
@@ -21,6 +21,7 @@
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
<a href="/"><img src="/public/logo.png" style="max-height:40px; padding-right: 0.75rem;"/></a>
|
||||
<% if(locals.title !== undefined) { %>
|
||||
<a href="/events?instance=<%= instance %>&bot=<%= bot %><%= subreddit !== undefined ? `&subreddit=${subreddit}` : '' %>"><%= title %></a>
|
||||
<% } %>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
statusEl.innerHTML = '<span class="iconify-inline green" data-icon="ep:circle-check-filled"></span>';
|
||||
break;
|
||||
default:
|
||||
dstatusEl.innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
|
||||
statusEl.innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
|
||||
break;
|
||||
}
|
||||
// data.page.updated_at
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
<a href="/"><img src="/public/logo.png" style="max-height:40px;"/></a>
|
||||
<% if(locals.instances !== undefined) { %>
|
||||
<ul class="inline-flex flex-wrap">
|
||||
<% instances.forEach(function (data) { %>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<li class="my-3 px-3">
|
||||
<span class="rounded-md py-2 px-3 border">
|
||||
<a class="font-normal pointer hover:font-bold" href="/bot/invites?instance=<%= instanceId %>&bot=<%= botData.system.name %>">
|
||||
Add Subreddit +
|
||||
Manage Subreddits Invites +
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
<a href="/"><img src="/public/logo.png" style="max-height:40px; padding-right: 0.75rem;"/></a>
|
||||
<% if(locals.title !== undefined) { %>
|
||||
<%= title %>
|
||||
<% } %>
|
||||
|
||||