Compare commits

..

39 Commits

Author SHA1 Message Date
FoxxMD
661a0ae440 Merge branch 'edge' 2022-05-26 09:59:32 -04:00
FoxxMD
05f477b67d Merge branch 'edge' 2022-05-12 12:27:51 -04:00
Matt Foxx
1317a5916c Merge pull request #86 from wchristian/example_fix
trying to use names key in authorfilter causes config parse failure
2022-04-05 16:55:56 -04:00
Christian Walde
e9135ec1ef trying to use names key in authorfilter causes config parse failure 2022-04-05 13:49:41 +02:00
FoxxMD
e58a0f8f21 Merge branch 'edge' 2022-03-14 12:39:05 -04:00
FoxxMD
f7cebc013b Merge branch 'edge' 2022-03-08 09:48:06 -05:00
FoxxMD
ae8e11feb4 Merge branch 'edge' 2022-02-22 11:11:46 -05:00
FoxxMD
e07b8cc291 Merge branch 'edge' 2022-02-18 11:58:28 -05:00
FoxxMD
fc51928054 Merge branch 'edge' 2022-02-02 16:59:56 -05:00
FoxxMD
e2590e50f8 Merge branch 'edge' 2022-01-28 17:27:51 -05:00
FoxxMD
aaed0d3419 Merge branch 'edge' 2022-01-21 10:46:11 -05:00
FoxxMD
bc7eff8928 Merge branch 'edge' 2022-01-14 15:27:09 -05:00
FoxxMD
d6954533a0 Merge branch 'edge' 2022-01-10 12:32:14 -05:00
FoxxMD
ba53233640 Merge branch 'edge' 2022-01-07 09:31:14 -05:00
FoxxMD
1ac7ad4724 Merge branch 'edge' 2022-01-03 16:35:01 -05:00
FoxxMD
2a282a0d6f Merge branch 'edge' 2021-12-21 09:35:21 -05:00
FoxxMD
fd5a92758d Merge branch 'edge' 2021-11-28 19:43:20 -05:00
FoxxMD
39daa11f2d Merge branch 'edge' 2021-11-15 12:53:28 -05:00
FoxxMD
dac6541e28 Merge branch 'edge' 2021-11-01 16:12:43 -04:00
FoxxMD
97906281e6 Merge branch 'edge' 2021-11-01 14:55:10 -04:00
FoxxMD
487f13f704 Merge branch 'edge' 2021-10-12 11:56:51 -04:00
FoxxMD
631e21452c Merge branch 'edge' 2021-09-28 16:36:13 -04:00
FoxxMD
4f3685a1f5 Merge branch 'edge' 2021-09-21 15:18:38 -04:00
FoxxMD
d2d945db2c Merge branch 'edge' 2021-09-21 15:08:28 -04:00
FoxxMD
910f7f79ef Merge branch 'edge' 2021-09-20 10:54:32 -04:00
FoxxMD
a11b667d5e Merge branch 'edge' 2021-09-13 16:16:55 -04:00
FoxxMD
885e3fa765 Merge branch 'edge' 2021-08-26 16:04:01 -04:00
FoxxMD
465c3c9acf Merge branch 'edge' 2021-08-20 15:02:24 -04:00
FoxxMD
161251a943 Merge branch 'edge' 2021-08-05 14:40:06 -04:00
FoxxMD
ce4cb96d9a Merge branch 'edge' 2021-08-03 23:39:14 -04:00
FoxxMD
c317f95953 Merge branch 'edge' 2021-08-03 22:43:02 -04:00
FoxxMD
d0e0515990 Merge branch 'edge' 2021-08-02 15:44:57 -04:00
FoxxMD
cdddd8de48 Merge branch 'edge' 2021-07-30 18:17:38 -04:00
FoxxMD
f598215d88 Merge branch 'edge' 2021-07-30 14:46:51 -04:00
FoxxMD
0c7218571c Merge branch 'edge' 2021-07-29 13:25:16 -04:00
FoxxMD
acc7c49e0e Merge branch 'edge' 2021-07-29 11:27:42 -04:00
FoxxMD
01839512d5 Merge branch 'edge' 2021-07-29 11:14:33 -04:00
FoxxMD
4680640b0c Merge branch 'develop' 2021-07-28 16:58:36 -04:00
Matt Foxx
b813ebdd96 Create dockerhub.yml 2021-07-28 11:27:04 -04:00
238 changed files with 4519 additions and 38194 deletions

View File

@@ -8,12 +8,11 @@ coverage
.idea
*.bak
*.sqlite
*.sqlite*
*.json
*.json5
*.yaml
*.yml
*.env
# exceptions
!heroku.yml

1
.github/FUNDING.yml vendored
View File

@@ -1,3 +1,2 @@
github: [FoxxMD]
patreon: FoxxMD
custom: ["bitcoincash:qqmpsh365r8n9jhp4p8ks7f7qdr7203cws4kmkmr8q"]

View File

@@ -1,3 +0,0 @@
{
"ref": "refs/heads/edge"
}

View File

@@ -1,14 +1,4 @@
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
name: Publish Docker image to Dockerhub
on:
push:
@@ -23,12 +13,8 @@ on:
jobs:
push_to_registry:
name: Build and Push Docker image to registries
name: Push Docker image to Docker Hub
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
@@ -39,22 +25,12 @@ 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
ghcr.io/foxxmd/context-mod
images: foxxmd/context-mod
# generate Docker tags based on the following events/attributes
tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
@@ -64,8 +40,7 @@ jobs:
latest=false
- name: Build and push Docker image
if: ${{ !env.ACT }}
uses: docker/build-push-action@v3
uses: docker/build-push-action@v2
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

2
.gitignore vendored
View File

@@ -336,7 +336,6 @@ web_modules/
# dotenv environment variables file
.env
.env.test
*.env
# parcel-bundler cache (https://parceljs.org/)
.cache
@@ -392,7 +391,6 @@ dist
*.json5
!src/Schema/*.json
!.github/push-hook-sample.json
!docs/**/*.json5
!docs/**/*.yaml
!docs/**/*.json

View File

@@ -120,10 +120,6 @@ ENV NPM_CONFIG_LOGLEVEL debug
# can set database to use more performant better-sqlite3 since we control everything
ENV DB_DRIVER=better-sqlite3
# NODE_ARGS are expanded after `node` command in the entrypoint IE "node {NODE_ARGS} src/index.js run"
# by default enforce better memory mangement by limiting max long-lived GC space to 512MB
ENV NODE_ARGS="--max_old_space_size=512"
ARG webPort=8085
ENV PORT=$webPort
EXPOSE $PORT

View File

@@ -30,9 +30,6 @@ Feature Highlights for **Moderators:**
* Event notification via Discord
* [**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):**
@@ -46,7 +43,6 @@ Feature highlights for **Developers and Hosting (Operators):**
* Historical statistics
* [Docker container support](/docs/operator/installation.md#docker-recommended)
* Easy, UI-based [OAuth authentication](/docs/operator/addingBot.md) for adding Bots and moderator dashboard
* Integration with [InfluxDB](https://www.influxdata.com) for detailed [time-series metrics](/docs/operator/database.md#influx) and a pre-built [Grafana](https://grafana.com) [dashboard](/docs/operator/database.md#grafana)
# Table of Contents
@@ -136,10 +132,6 @@ Moderator view/invite and authorization:
![Invite View](docs/images/oauth-invite.jpg)
A similar helper and invitation experience is available for adding **subreddits to an existing bot.**
![Subreddit Invite View](docs/images/subredditInvite.jpg)
### Configuration Editor
A built-in editor using [monaco-editor](https://microsoft.github.io/monaco-editor/) makes editing configurations easy:
@@ -153,13 +145,6 @@ A built-in editor using [monaco-editor](https://microsoft.github.io/monaco-edito
![Configuration View](docs/images/editor.jpg)
### [Grafana Dashboard](/docs/operator/database.md#grafana)
* Overall stats (active bots/subreddits, api calls, per second/hour/minute activity ingest)
* Over time graphs for events, per subreddit, and for individual rules/check/actions
![Grafana Dashboard](/docs/images/grafana.jpg)
## License
[MIT](/LICENSE)

View File

@@ -1,3 +0,0 @@
GITHUB_TOKEN=
DOCKERHUB_USERNAME=
DOCKER_PASSWORD=

View File

@@ -2,8 +2,6 @@
# used https://github.com/linuxserver/docker-plex as a template
# NODE_ARGS can be passed by ENV in docker command like "docker run foxxmd/context-mod -e NODE_ARGS=--optimize_for_size"
exec \
s6-setuidgid abc \
/usr/local/bin/node $NODE_ARGS /app/src/index.js run
/usr/local/bin/node /app/src/index.js run

View File

@@ -152,8 +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/cookbook)
* 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
* generated examples in json/yaml

View File

@@ -1,17 +1,5 @@
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/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 137 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

After

Width:  |  Height:  |  Size: 479 KiB

View File

@@ -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 independently but are packaged together such that they act as one piece of software:
CM is composed of two applications that operate indepedently 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.

View File

@@ -136,8 +136,6 @@ 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:

View File

@@ -95,7 +95,7 @@ The Retention Policy can be specified at operator level, bot, subreddit *overrid
operator:
name: u/MyRedditAccount
databaseConfig:
retention: '3 months' # each subreddit will retain 3 months of recorded events
retention: '3 months' # each subreddit will retain 3 more of recorded events
bots:
# all subreddits this bot moderates will have 3 month retention
- name: u/OneBotAccount
@@ -130,59 +130,3 @@ retention: 2000
runs:
...
```
# Influx
ContextMod supports writing detailed time-series data to [InfluxDB](https://www.influxdata.com/).
This data can be used to monitor the overall health, performance, and metrics for a ContextMod server. Currently, this data can **only be used by an Operator** as it requires access to the operator configuration and CM instance.
CM supports InfluxDB OSS > 2.3 or InfluxDB Cloud.
**Note:** This is an **advanced feature** and assumes you have enough technical knowledge to follow the documentation provided by each application to deploy and configure them. No support is guaranteed for installation, configuration, or use of Influx and Grafana.
## Supported Metrics
TBA
## Setup
### InfluxDB OSS
* Install [InfluxDB](https://docs.influxdata.com/influxdb/v2.3/install/)
* [Configure InfluxDB using the UI](https://docs.influxdata.com/influxdb/v2.3/install/#set-up-influxdb-through-the-ui)
* You will need **Username**, **Password**, **Organization Name**, and **Bucket Name** later for Grafana setup so make sure to record them somewhere
* [Create a Token](https://docs.influxdata.com/influxdb/v2.3/security/tokens/create-token/) with enough permissions to write/read to the bucket you configured
* After the token is created **view/copy the token** to clipboard by clicking the token name. You will need this for Grafana setup.
### ContextMod
Add the following block to the top-level of your operator configuration:
```yaml
influxConfig:
credentials:
url: 'http://localhost:8086' # URL to your influx DB instance
token: '9RtZ5YZ6bfEXAMPLENJsTSKg==' # token created in the previous step
org: MyOrg # organization created in the previous step
bucket: contextmod # name of the bucket created in the previous step
```
## Grafana
A pre-built dashboard for [Grafana](https://grafana.com) can be imported to display overall metrics/stats using InfluxDB data.
![Grafana Dashboard](/docs/images/grafana.jpg)
* Create a new Data Source using **InfluxDB** type
* Choose **Flux** for the **Query Language**
* Fill in the details for **URL**, **Basic Auth Details** and **InfluxDB Details** using the data you created in the [Influx Setup step](#influxdb-oss)
* Set **Min time interval** to `60s`
* Click **Save and test**
* Import Dashboard
* **Browse** the Dashboard pane
* Click **Import** and **upload** the [grafana dashboard json file](/docs/operator/grafana.json)
* Chose the data source you created from the **InfluxDB CM** dropdown
* Click **Import**
The dashboard can be filtered by **Bots** and **Subreddits** dropdowns at the top of the page to get more specific details.

View File

@@ -4,7 +4,9 @@ This getting started guide is for **Operators** -- that is, someone who wants to
* [Installation](#installation)
* [Create a Reddit Client](#create-a-reddit-client)
* [Start ContextMod](#start-contextmod)
* [Create a Minimum Configuration](#create-a-minimum-configuration)
* [Local Installation](#local-installation)
* [Docker Installation](#docker-installation)
* [Add a Bot to CM](#add-a-bot-to-cm)
* [Access The Dashboard](#access-the-dashboard)
* [What's Next?](#whats-next)
@@ -17,25 +19,29 @@ Follow the [installation](/docs/operator/installation.md) documentation. It is r
[Create a reddit client](/docs/operator/README.md#provisioning-a-reddit-client)
# Start ContextMod
# Create a Minimum Configuration
Start CM using the example command from your [installation](#installation) and visit http://localhost:8085
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.
The First Time Setup page will ask you to input:
# Start ContextMod With Configuration
* 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.
## Local Installation
**Write Config** and then restart CM. You have now created the [minimum configuration](/docs/operator/configuration.md#minimum-configuration) required to run CM.
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)
# Add A Bot to CM
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.
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.
# Access The Dashboard
@@ -51,4 +57,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). Additionally, on the dashboard click the **Help** button at the top of the page to get a guided tour of the dashboard.
You might also be interested in these [quick tips for using the web interface](/docs/webInterface.md)

File diff suppressed because it is too large Load Diff

View File

@@ -8,19 +8,15 @@ 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.
Images available from these registeries:
### [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod)
* [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):
An example of starting the container using the [minimum configuration](/docs/operator/operatorConfiguration.md#minimum-config) with a [configuration file](/docs/operator/operatorConfiguration.md#defining-configuration-via-file):
* 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 ghcr.io/foxxmd/context-mod:latest
docker run -d -v /host/path/folder:/config -p 8085:8085 foxxmd/context-mod
```
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`
@@ -37,7 +33,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 ghcr.io/foxxmd/context-mod:latest
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 foxxmd/context-mod
```
## Locally
@@ -80,21 +76,3 @@ Be aware that Heroku's [free dyno plan](https://devcenter.heroku.com/articles/fr
* The **Worker** dyno **will not** go to sleep but you will NOT be able to access the web interface. You can, however, still see how Cm is running by reading the logs for the dyno.
If you want to use a free dyno it is recommended you perform first-time setup (bot authentication and configuration, testing, etc...) with the **Web** dyno, then SWITCH to a **Worker** dyno so it can run 24/7.
# Memory Management
Node exhibits [lazy GC cleanup](https://github.com/FoxxMD/context-mod/issues/90#issuecomment-1190384006) which can result in memory usage for long-running CM instances increasing to unreasonable levels. This problem does not seem to be an issue with CM itself but with Node's GC approach. The increase does not affect CM's performance and, for systems with less memory, the Node *should* limit memory usage based on total available.
In practice CM uses ~130MB for a single bot, single subreddit setup. Up to ~350MB for many (10+) bots or many (20+) subreddits.
If you need to reign in CM's memory usage for some reason this can be addressed by setting an upper limit for memory usage with `node` args by using either:
**--max_old_space_size=**
Value is megabytes. This sets an explicit limit on GC memory usage.
This is set by default in the [Docker](#docker-recommended) container using the env `NODE_ARGS` to `--max_old_space_size=512`. It can be disabled by overriding the ENV.
**--optimize_for_size**
Tells Node to optimize for (less) memory usage rather than some performance optimizations. This option is not memory size dependent. In practice performance does not seem to be affected and it reduces (but not entirely prevents) memory increases over long periods.

View File

@@ -1,95 +0,0 @@
This section is for **reddit moderators**. It covers how to use a CM bot for your subreddit.
If you are trying to run a ContextMod instance (the actual software) please refer to the [operator section](/docs/operator/README.md).
# Table of Contents
* [Overview](#overview)
* [Your Relationship to CM](#your-relationship-to-cm)
* [Operator](#operator)
* [Your Bot](#your-bot)
* [Getting Started](#getting-started)
* [Accessing The Bot](#accessing-the-bot)
* [Editing The Bot](#editing-the-bot)
* [Configuration](#configuration)
* [Guest Access](#guest-access)
# Overview
The Context Mod **software** can manage multiple **bots** (reddit accounts used as bots, like `/u/MyCMBot`). Each bot can manage (run) multiple **subreddits** which is determined by the subreddits the account is a moderator of.
You, the moderator of a subreddit a CM bot runs in, can access/manage the Bot using the CM software's [web interface](/docs/images/subredditStatus.jpg) and control its behavior using the [web editor.](/docs/images/editor.jpg)
## Your Relationship to CM
It is important to understand the relationship between you (the moderator), the bot, and the operator (the person running the CM software).
The easiest way to think about this is in relation to how you use Automoderator and interact with Reddit as a moderator. As an analogy:
### Operator
The operator is the person running the actual server/machine the Context Mod software is on.
They are best thought of as **Reddit:**
* Mostly hands-off when it comes to the bot and interacting with your subreddit
* You must interact with Reddit first before you can use automoderator (login, create a subreddit, etc...)
Unlike reddit, though, there is a greater level of trust required between you and the Operator because what you make the Bot do ultimately affects the Operator since they are the ones actually running your Bot and making API calls to reddit.
### Your Bot
Your bot is like an **invite-only version of Automoderator**:
* Unlike automoderator, you **must** interact with the Operator in order to get the bot working. It is not public for anyone to use.
* Like automoderator, you **must** create a [configuration](/docs/subreddit/components/README.md) for it do anything.
* The bot does not come pre-configured for you. It is a blank slate and requires user input to be useful.
* Also like automoderator, you are **entirely in control of the bot.**
* You can start, stop, and edit its behavior at any time without needing to communicate with the Operator.
* CM provides you _tools_, different ways the Bot can detect patterns in your subreddit/users as well as actions it can, and you can decide to use them however you want.
* Your bot is **only accessible to moderators of your subreddit.**
# Getting Started
The [Getting Started](/docs/subreddit/gettingStarted.md) guide lays out the steps needed to go from nothing to a working Bot. If you are a moderator new to Context Mod this is where you want to begin.
# Accessing The Bot
All bot management and editing is done through the [web interface.](/docs/images/subredditStatus.jpg) The URL used for accessing this interface is given to you by the **Operator** once they have agreed to host your bot/subreddit.
NOTE: This interface is **only access to moderators of your subreddit** and [guests.](#guest-access) You must login to the web interface **with your moderator account** in order to access it.
A **guided tour** that helps show how to manage the bot at a high-level is available on the web interface by clicking the **Help** button in the top-right of the page.
## Editing The Bot
Find the [editor in the web interface](/docs/webInterface.md#editingupdating-your-config) to access the built-in editor for the bot.
[The editor](/docs/images/editor.jpg) should be your all-in-one location for viewing and editing your bot's behavior. **It is equivalent to Automoderator's editor page.**
The editor features:
* syntax validation and highlighting
* configuration auto-complete and documentation (hover over properties)
* built-in validation using Microsoft Word "squiggly lines" indicators and an error list at the bottom of the window
* built-in saving (at the top of the window)
# Configuration
Use the [Configuration Reference](/docs/subreddit/components/README.md) to learn about all the different components available for building a CM configuration.
Additionally, refer to [How It Works](/docs/README.md#how-it-works) and [Core Concepts](/docs/README.md#concepts) to learn the basic of CM configuration.
After you have the basics under your belt you could use the [subreddit configurations cookbook](/docs/subreddit/components/cookbook) to familiarize yourself with a complete configuration and ways to use CM.
# Guest Access
CM supports **Guest Access**. Reddit users who are given Guest Access to your bot are allowed to access the web interface even though they are not moderators.
Additionally, they can edit the subreddit's config using the bot. If a Guest edits your config their username will be mentioned in the wiki page edit reason.
Guests can do everything a regular mod can except view/add/remove Guest. They can be removed at any time or set with an expiration date that their access is removed on.
**Guests are helpful if you are new to CM and know reddit users that can help you get started.**
[Add guests from the Subreddit tab in the main interface.](/docs/images/guests.jpg)

View File

@@ -1,150 +1,47 @@
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.
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.
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
```
This activity is a {{item.kind}} with {{item.votes}} votes, created {{item.age}} ago.
```
Produces:
> This activity is a submission with 10 votes created 5 minutes ago.
### Common
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 |
| `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... |
```json5
### Submissions
If the **Activity** is a Submission these additional properties are accessible:
| Name | Description | Example |
|---------------|-----------------------------------------------------------------|-------------------------|
| `upvoteRatio` | The upvote ratio | 100% |
| `nsfw` | If the submission is marked as NSFW | true |
| `spoiler` | If the submission is marked as a spoiler | true |
| `url` | If the submission was a link then this is the URL for that link | http://example.com |
| `title` | The title of the submission | Test post please ignore |
### Comments
If the **Activity** is a Comment these additional properties are accessible:
| Name | Description | Example |
|------|--------------------------------------------------------------|---------|
| `op` | If the Author is the OP of the Submission this comment is in | true |
### Moderator
If the **Activity** occurred in a Subreddit the Bot moderates these properties are accessible:
| Name | Description | Example |
|---------------|-------------------------------------|---------|
| `reports` | The number of reports recieved | 1 |
| `modReports` | The number of reports by moderators | 1 |
| `userReports` | The number of reports by users | 1 |
## Rule Data
### Summary
A summary of what rules were processed and which were triggered, with results, is available using the `ruleSummary` variable. Example:
{
item: {
kind: 'string', // the type of item (comment/submission)
author: 'string', // name of the item author (reddit user)
permalink: 'string', // a url to the item
url: 'string', // if the item is a Submission then its URL (external for link type submission, reddit link for self-posts)
title: 'string', // if the item is a Submission, then the title of the Submission,
botLink: 'string' // a link to the bot's FAQ
},
rules: {
// contains all rules that were run and are accessible using the name, lowercased, with all spaces/dashes/underscores removed
}
}
```
A summary of rules processed for this activity:
{{ruleSummary}}
```
Would produce:
> A summary of rules processed for this activity:
>
> * namedRegexRule - ✘
> * nameAttributionRule - ✓ - 1 Attribution(s) met the threshold of < 20%, with 1 (3%) of 32 Total -- window: 6 months
> * noXPost ✓ - ✓ 1 of 1 unique items repeated <= 3 times, largest repeat: 1
### Individual
Individual **Rules** can be accessed using the name of the rule, **lower-cased, with all spaces/dashes/underscores.** Example:
The properties of `rules` are accessible using the name, lower-cased, with all spaces/dashes/underscores. If no name is given `kind` is used as `name` Example:
```
Submission was repeated {{rules.noxpost.largestRepeat}} times
```
Produces
> Submission was repeated 7 times
## 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:
"rules": [
{
"name": "My Custom-Recent Activity Rule", // mycustomrecentactivityrule
"kind": "recentActivity"
},
{
// name = repeatsubmission
"kind": "repeatActivity",
}
]
```
A summary of actions processed for this activity, so far:
{{actionSummary}}
```
**To see what data is available for individual Rules [consult the schema](#configuration) for each Rule.**
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
#### 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:

View File

@@ -21,8 +21,6 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
* [Author](#author)
* [Regex](#regex)
* [Repost](#repost)
* [Sentiment Analysis](#sentiment-analysis)
* [Toxic Content Prediction](#moderatehatespeechcom-predictions)
* [Rule Sets](#rule-sets)
* [Actions](#actions)
* [Named Actions](#named-actions)
@@ -30,7 +28,6 @@ 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)
@@ -42,13 +39,10 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
* [Message](#message)
* [Remove](#remove)
* [Report](#report)
* [Toolbox UserNote](#usernote)
* [Mod Note](#mod-note)
* [UserNote](#usernote)
* [Filters](#filters)
* [Filter Types](#filter-types)
* [Author Filter](#author-filter)
* [Mod Notes/Actions](#mod-actionsnotes-filter)
* [Toolbox UserNotes](#toolbox-usernotes-filter)
* [Item Filter](#item-filter)
* [Subreddit Filter](#subreddit-filter)
* [Named Filters](#named-filters)
@@ -69,8 +63,6 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
* [Check Order](#check-order)
* [Rule Order](#rule-order)
* [Configuration Re-use and Caching](#configuration-re-use-and-caching)
* [Partial Configurations](#partial-configurations)
* [Sharing Configs Between Subreddits](#sharing-full-configs-as-runs)
* [Subreddit-ready examples](#subreddit-ready-examples)
# Runs
@@ -373,18 +365,6 @@ The **Repost** rule is used to find reposts for both **Submissions** and **Comme
This rule is for searching **all of Reddit** for reposts, as opposed to just the history of the Author of the Activity being checked. If you only want to check for reposts by the Author of the Activity being checked you should use the [Repeat Activity](/docs/subreddit/components/repeatActivity) rule.
### Sentiment Analysis
[**Full Documentation**](/docs/subreddit/components/sentiment)
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.
@@ -503,30 +483,11 @@ actions:
### Comment
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)
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)
* 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
@@ -534,90 +495,7 @@ 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....'
```
### Comment As Subreddit
ContextMod can comment [as the subreddit](https://www.reddit.com/r/modnews/comments/wpy5c8/announcing_remove_as_a_subreddit/) using the `/u/subreddit-ModTeam` account with some restrictions:
* The activity being replied to must ALREADY BE REMOVED.
* You can use the [Remove Action](#remove) beforehand to ensure this is the case.
* The created comment will always be stickied and distinguished
Usage:
```yaml
actions:
- kind: comment
asModTeam: true
content: string # required, the content of the comment
lock: boolean # lock the comment after creation
targets: string # 'self' or 'parent' or 'https://reddit.com/r/someSubreddit/21nfdi....'
```
### Submission
Create a Submission [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FSubmissionActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
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
@@ -719,35 +597,31 @@ 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
* 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)
* `content` can be [templated](#templating) and use [URL Tokens](#url-tokens)
```yaml
actions:
- kind: message
asSubreddit: true
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
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
```
### Remove
Remove the Activity being processed. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FRemoveActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json)
* **note** can be [templated](#templating)
* **reasonId** IDs can be found in the [editor](/docs/webInterface.md) using the **Removal Reasons** popup
If neither note nor reasonId are included then no removal reason is added.
```yaml
actions:
- kind: remove
spam: false # optional, mark as spam on removal
note: 'a moderator-readable note' # optional, a note only visible to moderators (new reddit only)
reasonId: '2n0f4674-365e-46d2-8fc7-a337d85d5340' # optional, the ID of a removal reason to add to the removal action (new reddit only)
spam: boolean # optional, mark as spam on removal
```
#### What About Removal Reason?
Reddit does not support setting a removal reason through the API. Please complain in [r/modsupport](https://www.reddit.com/r/modsupport) or [r/redditdev](https://www.reddit.com/r/redditdev) to help get this added :)
### Report
Report the Activity being processed. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FReportActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json)
@@ -774,32 +648,7 @@ actions:
- kind: usernote
type: spamwarn
content: 'Usernote message'
existingNoteCheck: boolean # if true (default) then the usernote will not be added if the same note appears for this activity
```
### Mod Note
[**Full Documentation**](/docs/subreddit/components/modActions/README.md#mod-note-action)
Add a [Mod Note](https://www.reddit.com/r/modnews/comments/t8vafc/announcing_mod_notes/) for the Author of the Activity.
* `type` must be one of the [valid note labels](https://www.reddit.com/dev/api#POST_api_mod_notes):
* BOT_BAN
* PERMA_BAN
* BAN
* ABUSE_WARNING
* SPAM_WARNING
* SPAM_WATCH
* SOLID_CONTRIBUTOR
* HELPFUL_USER
```yaml
actions:
- kind: modnote
type: SPAM_WATCH
content: 'a note only mods can see message' # optional
referenceActivity: boolean # if true the Note will be linked to the Activity being processed
existingNoteCheck: boolean # if true (default) then the note will not be added if the same note appears for this activity
allowDuplicate: boolean # if false then the usernote will not be added if the same note appears for this activity
```
# Filters
@@ -883,14 +732,6 @@ There are two types of Filter. Both types have the same "shape" in the configura
Test the Author of an Activity. See [Schema documentation](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json) for all possible Author Criteria
#### Mod Actions/Notes Filter
See [Mod Actions/Notes](/docs/subreddit/components/modActions/README.md#mod-action-filter) documentation.
#### Toolbox UserNotes Filter
See [UserNotes](/docs/subreddit/components/userNotes/README.md) documentation
### Item Filter
Test for properties of an Activity:
@@ -1190,7 +1031,7 @@ If the Check is using `AND` condition for its rules (default) then if either Rul
**It is therefore advantageous to list your lightweight Rules first in each Check.**
## Configuration Re-use and Caching
### Configuration Re-use and Caching
ContextMod implements caching functionality for:
@@ -1214,186 +1055,6 @@ PROTIP: You can monitor the re-use of cache in the `Cache` section of your subre
[Learn more about how Caching works](/docs/operator/caching.md)
## Partial Configurations
ContextMod supports fetching parts of a configuration (a **Fragment**) from an external source. Fragments are an advanced feature and should only be used by users who are familiar with CM's configuration syntax and understand the risks/downsides associates with fragmenting a configuration.
**Fragments** are supported for:
* [Runs](#runs)
* [Checks](#checks)
* [Rules](#rules)
* [Actions](#actions)
### Should You Use Partial Configurations?
* **PROS**
* Consolidate shared configuration for many subreddits into one location
* Shared configuration can be updated independently of subreddits
* Allows sharing access to configuration outside of moderators of a specific subreddit or even reddit
* **CONS**
* Editor does not currently support viewing, editing, or updating Fragments. Only the Fragment URL is visible in a Subreddit's configuration
* No editor support for viewing obscures "complete view" of configuration and makes editor less useful for validation
* Currently, editor cannot validate individual Fragments. They must be copy-pasted "in place" within a normal configuration.
* Using external (non-wiki) sources means **you** are responsible for the security/access to the fragment
In general, Fragments should only be used to offload small, well-tested pieces of a configuration that can be shared between many subreddits. Examples:
* A regex Rule for spam links
* A Recent Activity Rule for reporting users from freekarma subreddits
### Usage
A Fragment may be either a special string or a Fragment object. The fetched Fragment can be either an object or an array of objects of the type of Fragment being replaced.
**String**
If value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit
* EX `wiki:botconfig/myFragment` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/myFragment`
If the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page
* EX `wiki:myFragment/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/myFragment/test`
If the value starts with `url:` then the value is fetched as an external url and expects raw text returned
* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`
**Object**
The object contains:
* `path` -- REQUIRED string following rules above
* `ttl` -- OPTIONAL, number of seconds to cache the URL result. Defaults to `WikiTTL`
### Sharing Full Configs as Runs
If the Fragment fetched by CM is a "full config" (including `runs`, `polling`, etc...) that could be used as a valid config for another subreddit then CM will extract and use the **Runs** from that config.
**However, the config must also explicitly allow access for use as a Fragment.** This is to prevent subreddits that share a Bot account from accidentally (or intentionally) gaining access to another subreddit's config with permissions.
#### Sharing
The config that will be shared (accessed at `wiki:botconfig/contextbot|SharingSubreddit`) must have the `sharing` property defined at its top-level. If `sharing` is not defined access will be denied for all subreddits.
```yaml
sharing: false # deny access to all subreddits (default when sharing is not defined)
polling:
- newComm
runs:
# ...
```
```yaml
sharing: true # any subreddit can use this config (reddit account must also be able to access wiki page)
```
```yaml
# when a list is given all subreddit names that match any from the list are ALLOWED to access the config
# list can be regular expressions or case-insensitive strings
sharing:
- mealtimevideos
- videos
- '/Ask.*/i'
```
```yaml
# if `exclude` is used then any subreddit name that is NOT on this list can access the config
# list can be regular expressions or case-insensitive strings
sharing:
exclude:
- mealtimevideos
- videos
- '/Ask.*/i'
```
#### Examples
**Replacing A Rule with a URL Fragment**
```yaml
runs:
- checks:
- name: Free Karma Alert
description: Check if author has posted in 'freekarma' subreddits
kind: submission
rules:
- 'url:https://gist.githubusercontent.com/FoxxMD/0e1ee1ab950ff4d1f0cd26172bae7f8f/raw/0ebfaca903e4a651827effac5775c8718fb6e1f2/fragmentRule.yaml'
- name: badSub
kind: recentActivity
useSubmissionAsReference: false
thresholds:
# if the number of activities (sub/comment) found CUMULATIVELY in the subreddits listed is
# equal to or greater than 1 then the rule is triggered
- threshold: '>= 1'
subreddits:
- MyBadSubreddit
window: 7 days
actions:
- kind: report
content: 'uses freekarma subreddits and bad subreddits'
```
**Replacing A Rule with a URL Fragment (Multiple)**
```yaml
runs:
- checks:
- name: Free Karma Alert
description: Check if author has posted in 'freekarma' subreddits
kind: submission
rules:
- 'url:https://gist.githubusercontent.com/FoxxMD/0e1ee1ab950ff4d1f0cd26172bae7f8f/raw/0ebfaca903e4a651827effac5775c8718fb6e1f2/fragmentRuleArray.yaml'
actions:
- kind: report
content: 'uses freekarma subreddits and bad subreddits'
```
**Replacing A Rule with a Wiki Fragment**
```yaml
runs:
- checks:
- name: Free Karma Alert
description: Check if author has posted in 'freekarma' subreddits
kind: submission
rules:
- 'wiki:freeKarmaFrag'
actions:
- kind: report
content: 'uses freekarma subreddits'
```
**Using Another Subreddit's Config**
```yaml
runs:
- `wiki:botconfig/contextbot|SharingSubreddit`
- name: MySubredditSpecificRun
checks:
- name: Free Karma Alert
description: Check if author has posted in 'freekarma' subreddits
kind: submission
rules:
- 'wiki:freeKarmaFrag'
actions:
- kind: report
content: 'uses freekarma subreddits'
```
In `r/SharingSubreddit`:
```yaml
sharing: true
runs:
- name: ARun
# ...
```
# Subreddit-Ready Examples
Refer to the [Subreddit Cookbook Examples](/docs/subreddit/components/cookbook) section to find ready-to-use configurations for common scenarios (spam, freekarma blocking, etc...). This is also a good place to familiarize yourself with what complete configurations look like.
Refer to the [Subreddit-Ready Examples](/docs/subreddit/components/subredditReady) section to find ready-to-use configurations for common scenarios (spam, freekarma blocking, etc...). This is also a good place to familiarize yourself with what complete configurations look like.

View File

@@ -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
// OR = any of the nested Rules will be the Rule Set trigger
// AND = 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

View File

@@ -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
# OR = any of the nested Rules will be the Rule Set trigger
# AND = 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

View File

@@ -8,24 +8,7 @@ The **Attribution** rule will aggregate an Author's content Attribution (youtube
Consult the [schema](https://json-schema.app/view/%23/%23%2Fdefinitions%2FCheckJson/%23%2Fdefinitions%2FAttributionJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
# [Template Variables](/docs/subreddit/actionTemplating.md)
### Examples
| Name | Description | Example |
|------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | 1 Attribution(s) met the threshold of >= 20%, with 6 (40%) of 15 Total -- window: 3 years |
| `triggeredDomainCount` | Number of domains that met the threshold | 1 |
| `window` | Number or duration of Activities considered from window | 3 years |
| `largestCount` | The count from the largest aggregated domain | 6 |
| `largestPercentage` | The percentage of Activities the largest aggregated domain comprises | 40% |
| `smallestCount` | The count from the smallest aggregated domain | 1 |
| `smallestPercentage` | The percentage of Activities the smallest aggregated domain comprises | 6% |
| `countRange` | A convenience string displaying "smallestCount - largestCount" or just one number if both are the same | 5 |
| `percentRange` | A convenience string displaying "smallestPercentage - largestPercentage" or just one percentage if both are the same | 34% |
| `domainsDelim` | A comma-delimited list of all the domain URLs that met the threshold | youtube.com/example1, youtube.com/example2, rueters.com |
| `titlesDelim` | A comma-delimited list of friendly-names of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => "My Youtube Channel Title") | My Channel A, My Channel B, reuters.com |
| `threshold` | The threshold you configured for this Rule to trigger | `>= 20%` |
# Examples
* Self Promotion as percentage of all Activities [YAML](/docs/subreddit/components/attribution/redditSelfPromoAll.yaml) | [JSON](/docs/subreddit/components/attribution/redditSelfPromoAll.json5) - Check if Author is submitting much more than they comment.
* Self Promotion as percentage of all Activities [YAML](/docs/subreddit/componentscomponents/attribution/redditSelfPromoAll.yaml) | [JSON](/docs/subreddit/componentscomponents/attribution/redditSelfPromoAll.json5) - Check if Author is submitting much more than they comment.
* Self Promotion as percentage of Submissions [YAML](/docs/subreddit/components/attribution/redditSelfPromoSubmissionsOnly.yaml) | [JSON](/docs/examplesm/attribution/redditSelfPromoSubmissionsOnly.json5) - Check if any of Author's aggregated submission origins are >10% of their submissions

View File

@@ -9,7 +9,7 @@ The **Author** rule triggers if any [AuthorCriteria](https://json-schema.app/vie
* author's subreddit flair text
* author's subreddit flair css
* author's subreddit mod status
* [Toolbox User Notes](/docs/subreddit/components/userNotes)
* [Toolbox User Notes](/docs/subreddit/componentscomponents/userNotes)
The Author **Rule** is best used in conjunction with other Rules to short-circuit a Check based on who the Author is. It is easier to use a Rule to do this then to write **author filters** for every Rule (and makes Rules more re-useable).
@@ -18,10 +18,10 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorRule
### Examples
* Basic examples
* Flair new user Submission [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - If the Author does not have the `vet` flair then flair the Submission with `New User`
* Flair vetted user Submission [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - If the Author does have the `vet` flair then flair the Submission with `Vetted`
* Flair new user Submission [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - If the Author does not have the `vet` flair then flair the Submission with `New User`
* Flair vetted user Submission [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - If the Author does have the `vet` flair then flair the Submission with `Vetted`
* Used with other Rules
* Ignore vetted user [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - Short-circuit the Check if the Author has the `vet` flair
* Ignore vetted user [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - Short-circuit the Check if the Author has the `vet` flair
## Filter
@@ -35,7 +35,7 @@ All **Rules** and **Checks** have an optional `authorIs` property that takes an
### Examples
* Skip recent activity check based on author [YAML](/docs/subreddit/components/author/authorFilter.yaml) | [JSON](/docs/subreddit/components/author/authorFilter.json5) - Skip a Recent Activity check for a set of subreddits if the Author of the Submission has any set of flairs.
* Skip recent activity check based on author [YAML](/docs/subreddit/componentscomponents/author/authorFilter.yaml) | [JSON](/docs/subreddit/componentscomponents/author/authorFilter.json5) - Skip a Recent Activity check for a set of subreddits if the Author of the Submission has any set of flairs.
## Flair users and submissions
@@ -45,4 +45,4 @@ Consult [User Flair schema](https://json-schema.app/view/%23%2Fdefinitions%2FUse
### Examples
* OnlyFans submissions [YAML](/docs/subreddit/components/author/onlyfansFlair.yaml) | [JSON](/docs/subreddit/components/author/onlyfansFlair.json5) - Check whether submitter has typical OF keywords in their profile and flair both author + submission accordingly.
* OnlyFans submissions [YAML](/docs/subreddit/componentscomponents/author/onlyfansFlair.yaml) | [JSON](/docs/subreddit/componentscomponents/author/onlyfansFlair.json5) - Check whether submitter has typical OF keywords in their profile and flair both author + submission accordingly.

View File

@@ -40,7 +40,7 @@
// for this to pass the Author of the Submission must not have the flair "Supreme Memer" and have the name "user1" or "user2"
{
"flairText": ["Supreme Memer"],
"names": ["user1","user2"]
"name": ["user1","user2"]
},
{
// for this to pass the Author of the Submission must not have the flair "Decent Memer"

View File

@@ -30,7 +30,7 @@ runs:
# for this to pass the Author of the Submission must not have the flair "Supreme Memer" and have the name "user1" or "user2"
- flairText:
- Supreme Memer
names:
name:
- user1
- user2
# for this to pass the Author of the Submission must not have the flair "Decent Memer"

View File

@@ -1,201 +0,0 @@
# ContextMod Cookbook
Here you will find useful configs for CM that provide real-world functionality. This is where you should look first for **"how do i..."** questions.
## How To Use
Each recipe includes what type of config piece it is (Rule, Check, Action, Run, etc...). Keep this in mind before copy-pasting to make sure it goes in the right place in your config.
### Copy-Pasting
If the type is **Check** or **Run** the recipe contents will have instructions in the comments on how to use it as a **full subreddit config** OR **by itself (default).** If not Check/Run then when copy-pasting you will need to ensure it is placed in the correct spot in your config.
### As Config Fragment
**Checks, Runs, Actions, and Rule** recipes can be referenced in your config without copy-pasting by using them as [Config Fragments.](/docs/subreddit/components/README.md#partial-configurations) These need to be placed in the correct spot in your config, just like copy-pasting, but only require the URL of the recipe instead of all the code.
To use a recipe as a fragment **copy** the URL of the config and insert into your config like this:
```yaml
- 'url:https://URL_TO_CONFIG'
```
EXAMPLE: Using the **Config** link from the [Free Karma](#remove-submissions-from-users-who-have-used-freekarma-subs-to-bypass-karma-checks) check below -- copy the **Config** link and insert it into a full subreddit config like this:
<details>
<summary>Config</summary>
```yaml
polling:
- newSub
runs:
- name: MyFirstRun
checks:
# freekarma check
- 'url:https://github.com/FoxxMD/context-mod/blob/master/docs/subreddit/components/cookbook/freekarma.yaml'
- name: MyRegularCheck
kind: submission
# ...
```
</details>
# Recipes
## Spam Prevention
### Remove submissions from users who have used 'freekarma' subs to bypass karma checks
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/freekarma.yaml)
If the user has any activity (comment/submission) in known freekarma subreddits in the past (100 activities) then remove the submission.
### Remove submissions that are consecutively spammed by the author
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/crosspostSpam.yaml)
If the user has crossposted the same submission in the past (100 activities) 4 or more times in a row then remove the submission.
### Remove submissions if users is flooding new
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/floodingNewSubmissions.yaml)
If the user has made more than 4 submissions in your subreddit in the last 24 hours than new submissions are removed and user is tagged with a modnote.
### Remove submissions posted in diametrically-opposed subreddit
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/diametricSpam.yaml)
If the user makes the same submission to another subreddit(s) that are "thematically" opposed to your subreddit it is probably spam. This check removes it. Detects all types of submissions (including images).
### Remove comments that are consecutively spammed by the author
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/commentSpam.yaml)
If the user made the same comment (with some fuzzy matching) 4 or more times in a row in the past (100 activities or 6 months) then remove the comment.
### Remove comment if it is a chat invite link spam
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/chatSpam.yaml)
This rule goes a step further than automod can by being more discretionary about how it handles this type of spam.
* Remove the comment if:
* Comment being checked contains **only** a chat link (no other text) OR
* Chat links appear **anywhere** in three or more of the last 100 comments the Author has made
This way ContextMod can more easily distinguish between these use cases for a user commenting with a chat link:
* actual spammers who only spam a chat link
* users who may comment with a link but have context for it either in the current comment or in their history
* users who many comment with a link but it's a one-off event (no other links historically)
## Repost Detection
### Remove comments reposted from youtube video submissions
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/youtubeCommentRepost.yaml)
**Requires bot has an API Key for Youtube.**
Removes comment on reddit if the same comment is found on the youtube video the submission is for.
### Remove comments reposted from reddit submissions
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/commentRepost.yaml)
Checks top-level comments on submissions younger than 30 minutes:
* Finds other reddit submissions based on crosspost/duplicates/title/URL, takes top 10 submissions based # of upvotes
* If this comment matches any top comments from those other submissions with at least 85% sameness then it is considered a repost and removed
### Remove reposted reddit submission
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/submissionRepost.yaml)
Checks reddit for top posts with a **Title** that is 90% or more similar to the submission being checked and removes it, if found.
## Self Promotion
### Remove link submissions where the user's history is comprised of 10% or more of the same link
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/selfPromo.yaml)
If the link origin (youtube author, twitter author, etc. or regular domain for non-media links)
* comprises 10% or more of the users **entire** history in the past (100 activities or 6 months)
* or comprises 10% or more of the users **submission** history in the past (100 activities or 6 months) and the user has low engagement (<50% of history is comments or 40%> of comment are as OP)
then remove the submission
### Remove submissions posted in 'newtube' subreddits
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/newtube.yaml)
If the user makes the same submission to a 'newtube' or self-promotional subreddit it is removed and a modnote is added.
## Safety
### Remove comments on brigaded submissions when user has no history
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/brigadingNoHistory.yaml)
The users of comments on a brigaded submission (based on a special submission flair) have their comment history checked -- if they have no participation in your subreddit then the comment is removed.
### Remove submissions from users with a history of sex solicitation
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/sexSolicitationHistory.yaml)
If the author of a submission has submissions in their history that match common reddit "sex solicitation" tags (MFA, R4F, M4F, etc...) the submission is removed and a modnote added.
This is particularly useful for subreddits with underage audiences or mentally/emotionally vulnerable groups.
The check can be modified to removed comments by changing `kind: submission` to `kind: comment`
## Verification
### Verify users from r/TranscribersOfReddit
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/transcribersOfReddit.yaml)
[r/TranscribersOfReddit](https://www.reddit.com/r/transcribersofreddit) is a community of volunteers transcribing images and videos, across reddit, into plain text.
This Check detects their standard transcription template and also checks they have a history in r/transcribersofreddit -- then approves the comment and flairs the user with **Transcriber ✍️**
### Require submission authors have prior subreddit participation
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/requireNonOPParticipation.yaml)
Submission is removed if the author has **less than 5 non-OP comments** in your subreddit prior to making the submission.
### Require submission authors make a top-level comment with 15 minutes of posting
* Type: **Check**
* [Config](/docs/subreddit/components/cookbook/requireNonOPParticipation.yaml)
After making a submission the author must make a top-level comment with a regex-checkable pattern within X minutes. If the comment is not made the submission is removed.
# Monitoring
### Sticky a comment on popular submissions
* Type: **Run**
* [Config](/docs/subreddit/components/cookbook/popularSubmissionMonitoring.yaml)
This **Run** should come after any other Runs you have that may remove a Submission.
The Run will cause CM to check new submissions for 3 hours at a 10 minute interval. The bot will then make a comment and sticky it WHEN it detects the number of upvotes is abnormal for how long the Submission has been "alive".

View File

@@ -1,44 +0,0 @@
#polling:
# - newComm
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Report comments from users with no history in the subreddit IF the submission is flaired as being brigaded
# optionally, remove comment
#
- name: Brigading No History
kind: comment
# only runs on comments in a submission with a link flair css class of 'brigaded'
itemIs:
- submissionState:
# can use any or all of these to detect brigaded submission
- link_flair_css: brigaded
#flairTemplate: 123-1234
#link_flair_text: Restricted
rules:
- name: noHistory
kind: recentActivity
# check last 100 activities that have not been removed
window:
count: 100
filterOn:
post:
commentState:
include:
- removed: false
thresholds:
# triggers if user has only one activity (this one) in your subreddit
- subreddits:
- MYSUBREDDIT
threshold: '<= 1'
actions:
- kind: report
enable: true
content: User has no history in subreddit
- kind: remove
enable: false
note: User has no history in subreddit

View File

@@ -1,71 +0,0 @@
#polling:
# - newSub
# - newComm
#runs:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a series of RUNS
- name: approvals
checks:
- name: approveSubmissionOnComment
description: Approve an unapproved submission when OP comments with the magic words
kind: comment
itemIs:
# only check comment if submission is not approved and this comment is by OP
- submissionState:
- approved: false
op: true
rules:
- name: OPMagic
kind: regex
criteria:
# YOU NEED TO EDIT THIS REGEX TO MATCH THE PATTERN THE OP'S COMMENT SHOULD HAVE IN ORDER TO VERIFY THE SUBMISSION
- regex: '/Say Please/i'
actions:
- kind: approve
targets:
- parent
- self
# cancel any delayed dispatched actions
- kind: cancelDispatch
# tell action to look for delayed items matched parent (submission)
target: parent
# submission must have 'subVerification' identifier
identifier: subVerification
- name: verification
checks:
- name: waitForVerification
description: Delay processing this submission for 15 minutes
kind: submission
itemIs:
# only dispatch if this is the first time we are seeing this submission
- source:
- "poll:newSub"
- user
actions:
- kind: dispatch
target: self
# unique identifier which is a nice hint in the UI and also allows targeting this item while it is delayed
identifier: subVerification
delay: "15 minutes"
# when it is reprocessed go directly to the 'verification' run, skipping everything else
goto: verification
- name: removeNoVerification
description: Remove submission if it is not verified after delay
kind: submission
itemIs:
# only process this submission if it comes dispatch with 'subVerification' identifier and is NOT approved after 15 minutes
- source: "dispatch:subVerification"
approved: false
actions:
# if this submission is being processed it has been 5 minutes and was not cancelled by OF comment
- kind: remove
enable: true
- kind: comment
enable: true
lock: true
distinguish: true
content: 'Your submission has been removed because you did not follow verification instructions within 15 minutes of posting.'

View File

@@ -1,54 +0,0 @@
#polling:
# - newComm
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Checks top-level comments on submissions younger than 30 minutes:
# * Finds other reddit submissions based on crosspost/duplicates/title/URL, takes top 10 submissions based # of upvotes
# * If this comment matches any comments from those other submissions with at least 85% sameness then it is considered repost
#
# optionally, bans user if they have more than one modnote for comment reposts
#
- name: commRepost
description: Check if comment has been reposted from youtube
kind: comment
itemIs:
- removed: false
approved: false
op: false
# top level comments only
depth: '< 1'
submissionState:
- age: '< 30 minutes'
condition: AND
rules:
- name: commRepost
kind: repost
criteria:
- searchOn:
- external
actions:
- kind: remove
spam: true
note: 'reposted comment from reddit with {{rules.commrepost.closestSameness}}% sameness'
- kind: ban
authorIs:
# if the author has more than one spamwatch usernote then just ban em
include:
- modActions:
- noteType: SPAM_WATCH
note: "/comment repost.*/i"
search: total
count: "> 1"
message: You have been banned for repeated spammy behavior including reposting reddit comments
note: reddit comment repost + spammy behavior
reason: reddit comment repost + spammy behavior
- name: commRepostModNote
kind: modnote
content: 'YT comment repost with {{rules.commrepost.closestSameness}}% sameness'
type: SPAM_WATCH

View File

@@ -1,34 +0,0 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
- name: diametricSpam
description: Check if author has posted the same image in opposite subs
kind: submission
rules:
- name: recent
kind: recentActivity
useSubmissionAsReference: true
# requires your subreddit to be running on a CM instance that supports image processing
imageDetection:
enable: true
threshold: 5
lookAt: submissions
window: 30
thresholds:
- threshold: ">= 1"
subreddits:
- AnotherSubreddit
actions:
- kind: remove
enable: true
content: "Posted same image in {{rules.recent.subSummary}}"
- kind: comment
distinguish: true
sticky: true
lock: true
content: 'You have posted the same image in another subreddit ({{rules.recent.subSummary}}) that does not make sense given the theme of this subreddit. We consider this spam and it has been removed.'

View File

@@ -1,34 +0,0 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Add a mote note to users who are making more than 4 submissions a day
# and optionally remove new submissions by them
#
- name: Flooding New
description: Detect users make more than 4 submission in 24 hours
kind: submission
rules:
- name: Recent In Sub
kind: recentActivity
useSubmissionAsReference: false
window:
duration: 24 hours
fetch: submissions
thresholds:
- subreddits:
# change this to your subreddit
- MYSUBREDDIT
threshold: "> 4"
actions:
- kind: modnote
type: SPAM_WATCH
content: '{{rules.recentinsub.totalCount}} submissions in the last 24 hours'
- kind: remove
enable: false
note: '{{rules.recentinsub.totalCount}} submissions in the last 24 hours'

View File

@@ -1,45 +0,0 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Remove submissions from users who have recent activity in freekarma subs in the last 100 activities
#
- name: freekarma removal
description: Remove submission if user has used freekarma sub recently
kind: submission
rules:
- name: freekarma
kind: recentActivity
window: 100
useSubmissionAsReference: false
thresholds:
- subreddits:
- FreeKarma4U
- FreeKarma4You
- freekarmaforyou
- KarmaFarming4Pros
- KarmaStore
- upvote
- promote
- shamelessplug
- upvote
- FreeUpVotes
- GiveMeKarma
- nsfwkarma
- GetFreeKarmaAnyTime
- freekarma2021
- FreeKarma2022
- KarmaRocket
- FREEKARMA4PORN
actions:
- kind: report
enable: false
content: 'Remove => {{rules.freekarma.totalCount}} activities in freekarma subs'
- kind: remove
enable: true
note: '{{rules.freekarma.totalCount}} activities in freekarma subs'

View File

@@ -1,55 +0,0 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Add a mote note to users who make a submission that is also posted to a 'newtube' subreddit
# and optionally remove new submission
#
- name: Newtube Submission
description: Tag user if submission was posted in 'newtube' subreddit
kind: submission
rules:
- name: newTube
kind: recentActivity
window:
count: 100
fetch: submissions
thresholds:
- subreddits:
- AdvertiseYourVideos
- BrandNewTube
- FreeKarma4U
- FreeKarma4You
- KarmaStore
- GetMoreSubsYT
- GetMoreViewsYT
- NewTubers
- promote
- PromoteGamingVideos
- shamelessplug
- SelfPromotionYouTube
- SmallYTChannel
- SmallYoutubers
- upvote
- youtubestartups
- YouTube_startups
- YoutubeSelfPromotions
- YoutubeSelfPromotion
- YouTubeSubscribeBoost
- youtubepromotion
- YTPromo
- Youtubeviews
- YouTube_startups
actions:
- name: newtubeModTag
kind: modnote
type: SPAM_WATCH
content: 'New Tube => {{rules.newtube.subSummary}}{{rules.newtubeall.subSummary}}'
- kind: remove
enable: false
note: 'New Tube => {{rules.newtube.subSummary}}{{rules.newtubeall.subSummary}}'

View File

@@ -1,89 +0,0 @@
polling:
- newSub
runs:
- name: MyRegularRun
itemIs:
# regular run/checks should only run on new activities or if from dashboard
- source:
- 'poll:newSub'
- 'poll:newComm'
- 'user'
checks:
- name: RuleBreakingCheck1
kind: submission
# ...
#
# your regular checks go here
#
# assuming if a Submission makes it through all of your Checks then it is "OK"
# to be Approved or generally will be visible in the subreddit (valid for monitoring for r/All)
# -- at the end of the Run add a Dispath action
- name: Dispatch For Popular Monitoring
kind: submission
actions:
- kind: dispatch
identifier: 'popular'
# CM will wait 5 minutes before processing this submission again
delay: '5 minutes'
target: 'self'
# a separate run that only processes Submissions from dispatch:popular
- name: PopularWatch
itemIs:
- source: 'dispatch:popular'
checks:
# each check here looks at submission age and tests upvotes against what you think is probably r/All number of votes
# in descending age (oldest first)
# NOTE: You should change the 'age' and 'score' tests to fit the traffic volume for your subreddit!
- name: Two Hour Check
kind: submission
itemIs:
- age: '>= 2 hours'
score: '> 100'
actions:
- kind: comment
name: popularComment
content: 'Looks like this thread is getting a lot of attention. Greetings r/All! Please keep it civil.'
sticky: true
distinguish: true
lock: true
- name: One Hour Check
kind: submission
itemIs:
- age: '>= 1 hours'
score: '> 50'
actions:
- popularComment
- name: Thirty Minute Check
kind: submission
itemIs:
- age: '>= 30 minutes'
score: '> 25'
actions:
- popularComment
- name: Ten Minute Check
kind: submission
itemIs:
- age: '>= 10 minutes'
score: '> 10'
actions:
- popularComment
# finally, if none of the popular checks passed re-dispatch submission to be checked in another 10 minutes
- name: Delay Popular Check
kind: submission
postTrigger:
# don't need to add this Actioned Events
recordTo: false
itemIs:
# only monitor until submission is 3 hours old
- age: '<= 3 hours'
actions:
- kind: dispatch
identifier: 'popular'
delay: '10 minutes'
target: 'self'

View File

@@ -1,51 +0,0 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Report submissions by users with less than 5 non-OP comments in our subreddit
# and optionally remove the submission
#
- name: RequireEngagement
description: Remove submission if author has less than X non-op comments in our subreddit
kind: submission
rules:
- name: LittleEngagement
kind: recentActivity
lookAt: comments
useSubmissionAsReference: false
# bot will check the last 100 NON-OP comments from user's history
window:
count: 100
fetch: comments
filterOn:
post:
commentState:
- op: false
thresholds:
subreddits:
- MYSUBREDDIT
# rule is "triggered" if there are LESS THAN 5 comments in our subreddit in the window specified (currently 100 non-op comments)
threshold: '< 5'
actions:
- kind: report # report the submission
enable: true
# the text of the report
content: 'User has <5 non-OP comments in last 100 comments'
- kind: remove # remove the submission
enable: false
note: 'User has <5 non-OP comments in last 100 comments'
- kind: comment # reply to submission with a comment
enable: false
# contents of the comment
content: We require users to have a minimum level of engagement (>5 comments on other people's posts) in our subreddit before making submissions. Your submission has been automatically removed.
sticky: true
distinguish: true
lock: true

View File

@@ -1,33 +0,0 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Remove submission if user has any "redditor for [sex]..." submissions in their history
# and optionally bans user
#
- name: sexSpamHistory
description: Detect sex spam language in recent history and ban if found (most likely a bot)
kind: submission
rules:
- kind: regex
name: redditorFor
criteria:
# matches if text has common "looking for" acronym like F4M R4A etc...
- regex: '/[RFM]4[a-zA-Z\s0-9]/i'
totalMatchThreshold: "> 1"
window: 100
testOn:
- body
- title
actions:
- kind: remove
enable: true
note: 'Has sex solicitation submission history: {{rules.redditorfor.matchSample}}'
- kind: modnote
type: ABUSE_WARNING
content: 'Has sex solicitation submission history: {{rules.redditorfor.matchSample}}'

View File

@@ -1,31 +0,0 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
- name: BotRepost
description: Remove submission if it is likely a repost
kind: submission
rules:
# search reddit for similar submissions to see if it is a repost
- name: subRepost
kind: repost
criteria:
- searchOn:
# match found Submissions sameness using title against title of Submission being checked
- kind: title
# sameness (confidence) % of a title required to consider Submission being checked as a repost
matchScore: 90
actions:
# report the submission
- kind: report
enable: true
content: '{{rules.subrepost.closestSameness}} confidence this is a repost.'
# remove the submission
- kind: remove
enable: false
note: '{{rules.subrepost.closestSameness}} confidence this is a repost.'

View File

@@ -1,41 +0,0 @@
#polling:
# - newComm
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Detect top-level comments by users from r/transcribersofreddit
# and approve/flair the user
#
- name: transcriber comment
description: approve/flair transcribed video comment
kind: comment
itemIs:
# top-level comments
depth: '< 1'
condition: AND
rules:
- name: transcribedVideoFormat
kind: regex
criteria:
- regex: '/^[\n\r\s]*\*Video Transcription\*[\n\r]+---[\S\s]+---/gim'
- name: transcribersActivity
kind: recentActivity
window:
count: 100
duration: 1 week
useSubmissionAsReference: false
thresholds:
- subreddits:
- transcribersofreddit
actions:
- kind: approve
- name: flairTranscriber
kind: flair
authorIs:
exclude:
- flairText:
- Transcriber ✍️
text: Transcriber ✍️

View File

@@ -1,47 +0,0 @@
#polling:
# - newComm
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# If submission type is a youtube video CM will check top comments on the video and remove comment if it at least 85% the same
# optionally, bans user if they have more than one modnote for comment reposts
#
- name: commRepostYT
description: Check if comment has been reposted from youtube
kind: comment
itemIs:
- removed: false
approved: false
op: false
condition: AND
rules:
- name: commRepost
kind: repost
criteria:
- searchOn:
- external
actions:
- kind: remove
spam: true
note: 'reposted comment from youtube with {{rules.commrepostyt.closestSameness}}% sameness'
- kind: ban
authorIs:
# if the author has more than one spamwatch usernote then just ban em
include:
- modActions:
- noteType: SPAM_WATCH
note: "/comment repost.*/i"
search: total
count: "> 1"
message: You have been banned for repeated spammy behavior including reposting youtube comments
note: yt comment repost + spammy behavior
reason: yt comment repost + spammy behavior
- name: commRepostYTModNote
kind: modnote
content: 'YT comment repost with {{rules.commrepostyt.closestSameness}}% sameness'
type: SPAM_WATCH

View File

@@ -5,64 +5,10 @@ The **History** rule can check an Author's submission/comment statistics over a
* Submission total or percentage of All Activity
* Comment total or percentage of all Activity
* Comments made as OP (commented in their own Submission) total or percentage of all Comments
* Ratio of activities against another window of activities
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FHistoryJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
## Ratio
Use the `ratio` property in Criteria to test the [number of activities](/docs/subreddit/activitiesWindow.md) found in the parent criteria against the number of activities from _another_ [activity window](/docs/subreddit/activitiesWindow.md) defined in the ratio.
Example:
```yaml
- kind: history
criteria:
# "parent" criteria, returns all activities, in the last 100 from user's history, that occurred in r/mealtimevideos
- window:
count: 100
filterOn:
post:
subreddits:
include:
- mealtimevideos
ratio:
# "ratio" criteria, returns all activities, in the last 100 from user's history, that occurred in r/redditdev
window:
count: 100
filterOn:
post:
subreddits:
include:
- redditdev
# test (number of parent criteria activities) / (number of ratio critieria activities)
threshold: '> 1.2'
```
`threshold` may be a number or percentage `(number * 100)`
* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities
* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities
### Examples
* Low Comment Engagement [YAML](/docs/subreddit/components/history/lowEngagement.yaml) | [JSON](/docs/subreddit/components/history/lowEngagement.json5) - Check if Author is submitting much more than they comment.
* OP Comment Engagement [YAML](/docs/subreddit/components/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/components/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content
# [Template Variables](/docs/subreddit/actionTemplating.md)
| Name | Description | Example |
|----------------------|------------------------------------------------------------------------|----------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | Filtered Activities (7) were < 10 Items (2 months) |
| `activityTotal` | Total number of activities from window | 50 |
| `filteredTotal` | Total number of activities filtered from window | 7 |
| `filteredPercent` | Percentage of activities filtered from window | 14% |
| `submissionTotal` | Total number of filtered submissions from window | 4 |
| `submissionPercent` | Percentage of filtered submissions from window | 8% |
| `commentTotal` | Total number of filtered comments from window | 3 |
| `commentPercent` | Percentage of filtered comments from window | 6% |
| `opTotal` | Total number of comments as OP from filtered comments | 2 |
| `opPercent` | Percentage of comments as OP from filtered comments | 66% |
| `thresholdSummary` | A text summary of the first Criteria triggered with totals/percentages | Filtered Activities (7) were < 10 Items |
| `subredditBreakdown` | A markdown list of filtered activities by subreddit | * SubredditA - 5 (71%) \n * Subreddit B - 2 (28%) |
| `window` | Number or duration of Activities considered from window | 2 months |
* Low Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/lowEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/lowEngagement.json5) - Check if Author is submitting much more than they comment.
* OP Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content

View File

@@ -1,177 +0,0 @@
# Table of Contents
* [Overview](#overview)
* [MHS Predictions](#mhs-predictions)
* [Flagged](#flagged)
* [Confidence](#confidence)
* [Usage](#usage)
* [Minimal/Default Config](#minimaldefault-config)
* [Full Config](#full-config)
* [Historical Matching](#historical-matching)
* [Examples](#examples)
# Overview
[moderatehatespeech.com](https://moderatehatespeech.com/) (MHS) is a [non-profit initiative](https://moderatehatespeech.com/about/) to identify and fight toxic and hateful content online using programmatic technology such as machine learning models.
They offer a [toxic content prediction model](https://moderatehatespeech.com/framework/) specifically trained on and for [reddit content](https://www.reddit.com/r/redditdev/comments/xdscbo/updated_bot_backed_by_moderationoriented_ml_for/) as well as partnering [directly with subreddits.](https://moderatehatespeech.com/research/subreddit-program/).
Context Mod leverages their [API](https://moderatehatespeech.com/docs/) for toxic content predictions in the **MHS Rule.**
The **MHS Rule** sends an Activity's content (title or body) to MHS which returns a prediction on whether the content is toxic and actionable by a moderator.
## MHS Predictions
MHS's toxic content predictions return two indicators about the content it analyzed. Both are available as test conditions in ContextMod.
### Flagged
MHS returns a straight "Toxic or Normal" **flag** based on how it classifies the content.
Example
* `Normal` - "I love those pineapples"
* `Toxic` - "why are we having all these people from shithole countries coming here"
### Confidence
MHS returns how **confident** it is of the flag classification on a scale of 0 to 100.
Example
"why are we having all these people from shithole countries coming here"
* Flag = `Toxic`
* Confidence = `97.12` -> The model is 97% confident the content is `Toxic`
# Usage
**An MHS Api Key is required to use this Rule**. An API Key can be acquired, for free, by creating an account at [moderatehatespeech.com](https://moderatehatespeech.com).
The Key can be provided by the bot's Operator in the [bot config credentials](https://json-schema.app/view/%23/%23%2Fdefinitions%2FBotInstanceJsonConfig/%23%2Fdefinitions%2FBotCredentialsJsonConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fedge%2Fsrc%2FSchema%2FOperatorConfig.json) or in the subreddit's config in the top-level `credentials` property like this:
```yaml
credentials:
mhs:
apiKey: 'myMHSApiKey'
# the rest of your config below
polling:
# ...
runs:
# ...
```
### Minimal/Default Config
ContextMod provides a reasonable default configuration for the MHS Rule if you do not wish to configure it yourself. The default configuration will trigger the rule if the MHS prediction:
* flags as `toxic`
* with `90% or greater` confidence
Example
```yaml
rules:
- kind: mhs
# rest of your rules here...
```
### Full Config
| Property | Type | Description | Default |
|--------------|---------|-------------------------------------------------------------------------------------------|---------|
| `flagged` | boolean | Test whether content is flagged as toxic (true) or normal (false) | `true` |
| `confidence` | string | Comparison against a number 0 to 100 representing how confident MHS is in the prediction | `>= 90` |
| `testOn` | array | Which parts of the Activity to send to MHS. Options: `title` and/or `body` | `body` |
Example
```yaml
rules:
- kind: mhs
criteria:
flagged: true # triggers if MHs flags the content as toxic AND
confidence: '> 66' # MHS is 66% or more confident in its prediction
testOn: # send the body of the activity to the MHS prediction service
- body
```
#### Historical Matching
Like the [Sentiment](/docs/subreddit/components/sentiment#historical) and [Regex](/docs/subreddit/components/regex#historical) rules CM can also use MHS predictions to check content from the Author's history.
Example
```yaml
rules:
- kind: mhs
# ...same config as above but can include below...
historical:
mustMatchCurrent: true # if true then CM will not check author's history unless current Activity matches MHS prediction criteria
totalMatching: '> 1' # comparison for how many activities in history must match to trigger the rule
window: 10 # specify the range of activities to check in author's history
criteria: #... if specified, overrides parent-level criteria
```
# [Template Variables](/docs/subreddit/actionTemplating.md)
| Name | Description | Example |
|-----------------|-------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | Current Activity MHS Test: ✓ Confidence test (>= 90) PASSED MHS confidence of 99.85% Flagged pass condition of true (toxic) MATCHED MHS flag 'toxic' |
| `window` | Number or duration of Activities considered from window | 1 activities |
| `criteriaTest` | MHS value to test against | MHS confidence is > 95% |
| `totalMatching` | Total number of activities (current + historical) that matched `criteriaTest` | 1 |
# Examples
Report if MHS flags as toxic
```yaml
rules:
- kind: mhs
actions:
- kind: report
content: 'MHS flagged => {{rules.mhs.summary}}'
```
Report if MHS flags as toxic with 95% confidence
```yaml
rules:
- kind: mhs
criteria:
confidence: '>= 95'
actions:
- kind: report
content: 'MHS flagged => {{rules.mhs.summary}}'
```
Report if MHS flags as toxic and at least 3 recent activities in last 10 from author's history are also toxic
```yaml
rules:
- kind: mhs
historical:
window: 10
mustMatchCurrent: true
totalMatching: '>= 3'
actions:
- kind: report
content: 'MHS flagged => {{rules.mhs.summary}}'
```
Approve if MHS flags as NOT toxic with 95% confidence
```yaml
rules:
- kind: mhs
criteria:
confidence: '>= 95'
flagged: false
actions:
- kind: approve
```

View File

@@ -1,152 +0,0 @@
# Table of Contents
* [Overview](#overview)
* [Mod Note Action](#mod-note-action)
* [Mod Action Filter](#mod-action-filter)
* [API Usage](#api-usage)
* [When To Use?](#when-to-use)
* [Examples](#examples)
# Overview
[Mod Notes](https://www.reddit.com/r/modnews/comments/t8vafc/announcing_mod_notes/) is a feature for New Reddit that allow moderators to add short, categorizable notes to Users of their subreddit, optionally associating te note with a submission/comment the User made. They are inspired by [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) which are also [supported by ContextMod.](/docs/subreddit/components/userNotes) Reddit's **Mod Notes** also combine [Moderation Log](https://mods.reddithelp.com/hc/en-us/articles/360022402312-Moderation-Log) actions (**Mod Actions**) for the selected User alongside moderator notes, enabling a full "overview" of moderator interactions with a User in their subreddit.
ContextMod supports adding **Mod Notes** to an Author using an [Action](/docs/subreddit/components/README.md#mod-note) and using **Mod Actions/Mod Notes** as a criteria in an [Author Filter](/docs/subreddit/components/README.md#author-filter)
# Mod Note Action
[**Schema Reference**](https://json-schema.app/view/%23%2Fdefinitions%2FModNoteActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json)
* `type` must be one of the [valid note labels](https://www.reddit.com/dev/api#POST_api_mod_notes):
* BOT_BAN
* PERMA_BAN
* BAN
* ABUSE_WARNING
* SPAM_WARNING
* SPAM_WATCH
* SOLID_CONTRIBUTOR
* HELPFUL_USER
```yaml
actions:
- kind: modnote
type: SPAM_WATCH
content: 'a note only mods can see message' # optional
referenceActivity: boolean # if true the Note will be linked to the Activity being processed
```
# Mod Action Filter
ContextMod can use **Mod Actions** (from moderation log) and **Mod Notes** in an [Author Filter](/docs/subreddit/components/README.md#author-filter).
## API Usage
Notes/Actions are **not** included in the data Reddit returns for either an Author or an Activity. This means that, in most cases, ContextMod is required to make **one additional API call to Reddit during Activity processing** if Notes/Actions as used as part of an **Author Filter**.
The impact of this additional call is greatest when the Author Filter is used as part of a **Comment Check** or running for **every Activity** such as part of a Run. Take this example:
No Mod Action filtering
* CM makes 1 api call to return new comments, find 10 new comments across 6 users
* Processing each comment, with no other filters, requires 0 additional calls
* At the end of processing 10 comments, CM has used a total of 1 api call.
Mod Action Filtering Used
* CM makes 1 api call to return new comments, find 10 new comments across 6 users
* Processing each comment, with a mod action filter, requires 1 additional api call per user
* At the end of processing 10 comments, CM has used a total of **7 api calls**
### When To Use?
In general,**do not** use Mod Actions in a Filter if:
* The filter is on a [**Comment** Check](/docs/subreddit/components/README.md#checks) and your subreddit has a high volume of Comments
* The filter is on a [Run](/docs/subreddit/components/README.md#runs) and your subreddit has a high volume of Activities
If you need Mod Notes-like functionality for a high volume subreddit consider using [Toolbox UserNotes](/docs/subreddit/components/userNotes) instead.
In general, **do** use Mod Actions in a Filter if:
* The filter is on a [**Submission** Check](/docs/subreddit/components/README.md#checks)
* The filter is part of an [Author **Rule**](/docs/subreddit/components/README.md#author) that is processed as **late as possible in the rule order for a Check**
* Your subreddit has a low volume of Activities (less than 100 combined submissions/comments in a 10 minute period, for example)
* The filter is on an Action
## Usage and Examples
Filter by Mod Actions/Notes on an Author Filter are done using the `modActions` property:
```yaml
age: '> 1 month'
# ...
modActions:
- ...
```
There two valid shapes for the Mod Action criteria: [ModLogCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FModLogCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json) and [ModNoteCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FModNoteCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json).
### ModLogCriteria
Used for filtering by **Moderation Log** actions *and/or general notes*.
* `activityType` -- Optional. If Mod Action is associated with an activity specify it here. A list or one of:
* `submission`
* `comment`
* `type` -- Optional. The type of Mod Log Action. A list or one of:
* `INVITE`
* `NOTE`
* `REMOVAL`
* `SPAM`
* `APPROVAL`
* `description` -- additional mod log details (string) to filter by -- not documented by reddit. Can be string or regex string-like `/.* test/i`
* `details` -- additional mod log details (string) to filter by -- not documented by reddit. Can be string or regex string-like `/.* test/i`
```yaml
activityType: submission
type:
- REMOVAL
- SPAM
search: total
count: '> 3 in 1 week'
```
### ModNoteCriteria
Inherits `activityType` from ModLogCriteria. If either of the below properties in included on the criteria then any other ModLogCriteria-specific properties are **ignored**.
* `note` -- the contents of the note to match against. Can be one of or a list of strings/regex string-like `/.* test/i`
* `noteType` -- If specified by the note, the note type (see [Mod Note Action](#mod-note-action) type). Can be one of or a list of strings/regex string-like `/.* test/i`
```yaml
noteType: SOLID_CONTRIBUTOR
search: total
count: '> 3 in 1 week'
```
### Examples
Author has more than 2 submission approvals in the last month
```yaml
type: APPROVAL
activityType: submission
search: total
count: '> 2 in 1 month'
```
Author has at least 1 BAN note
```yaml
noteType: BAN
search: total
count: '>= 1'
```
Author has at least 3 notes which include the words "self" and "promotion" in the last month
```yaml
note: '/self.*promo/i'
activityType: submission
search: total
count: '>= 3 in 1 month'
```

View File

@@ -27,19 +27,5 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRecentActi
### Examples
* Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits
* Submission in Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently
# [Template Variables](/docs/subreddit/actionTemplating.md)
| Name | Description | Example |
|----------------------|------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | 9 activities found in 2 of the specified subreddits (out of 21 total) MET threshold of >= 1 activities -- subreddits: SubredditA, SubredditB |
| `window` | Number or duration of Activities considered from window | 100 activities |
| `subSummary` | Comma-delimited list of subreddits matched by the criteria | SubredditA, SubredditB |
| `subCount` | Number of subreddits that match the criteria | 2 |
| `totalCount` | Total number of activities found by criteria | 9 |
| `threshold` | The threshold used to trigger the rule | `>= 1` |
| `karmaThreshold` | If present, the karma threshold used to trigger the rule | `> 5` |
| `combinedKarma` | Total number of karma gained from the matched activities | 10 |
| `subredditBreakdown` | A markdown list of filtered activities by subreddit | * SubredditA - 5 (71%) \n * Subreddit B - 2 (28%) |
* Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits
* Submission in Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently

View File

@@ -11,19 +11,12 @@ Which can then be used in conjunction with a [`window`](https://github.com/FoxxM
### Examples
* Trigger if regex matches against the current activity - [YAML](/docs/subreddit/components/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchAnyCurrentActivity.json5)
* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/components/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchThresholdCurrentActivity.json5)
* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/components/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/components/regex/matchSubmissionParts.json5)
* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchHistoryActivity.json5)
* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5)
* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchTotalHistoryActivity.json5)
* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/components/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchSubsetHistoryActivity.json5)
* Remove comments that are spamming discord links - [YAML](/docs/subreddit/components/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/components/regex/removeDiscordSpam.json5)
* Trigger if regex matches against the current activity - [YAML](/docs/subreddit/componentscomponents/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchAnyCurrentActivity.json5)
* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.json5)
* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.json5)
* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.json5)
* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5)
* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.json5)
* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.json5)
* Remove comments that are spamming discord links - [YAML](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.json5)
* Differs from just using automod because this config can allow one-off/organic links from users who DO NOT spam discord links but will still remove the comment if the user is spamming them
# [Template Variables](/docs/subreddit/actionTemplating.md)
| Name | Description | Example |
|---------------|---------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | Criteria 1 ✓ -- Activity Match ✓ => 1 > 0 (Threshold > 0) and 1 Total Matches (Window: 1 Item) -- Matched Values: "example.com/test" |
| `matchSample` | A comma-delimited list of matches from activities | "example.com/test" |

View File

@@ -47,16 +47,5 @@ With only `gapAllowance: 2` this rule **would trigger** because the the 1 and 2
## Examples
* Crosspost Spamming [JSON](/docs/subreddit/components/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/components/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits
* Burst-posting [JSON](/docs/subreddit/components/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/components/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts
# [Template Variables](/docs/subreddit/actionTemplating.md)
| Name | Description | Example |
|-----------------------|---------------------------------------------------------|-------------------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | 1 of 1 unique items repeated >= 7 times, largest repeat: 22 |
| `window` | Number or duration of Activities considered from window | 100 activities |
| `threshold` | Number of repeats that trigger rule | `>= 7` |
| `totalTriggeringSets` | Number of sets of repeats that matched threshold | 1 |
| `largestRepeat` | The largest number of repeats in a single set | 22 |
| `gapAllowance` | Number of non-repeat activities allowed between repeats | 2 |
* Crosspost Spamming [JSON](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits
* Burst-posting [JSON](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts

View File

@@ -267,15 +267,6 @@ When the rule is run in a **Comment Check** you may specify text comparisons (li
* **minWordCount** -- The minimum number of words a comment must have
* **caseSensitive** -- If the match comparison should be case-sensitive (defaults to `false`)
# [Template Variables](/docs/subreddit/actionTemplating.md)
| Name | Description | Example |
|-------------------|--------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | Searched top 0 comments in top 10 most popular submissions, 50 Youtube comments and found 2 reposts. --- Closest Match => >> The thought of this is terrifying << from Youtube (https://youtube.com/watch?v=example) with 100.00% sameness. |
| `closestSummary` | If rule was triggered, the reposted activity type and where it came from | matched a comment from youtube |
| `closestSameness` | If rule was triggered, the sameness of repost to the current activity | 100% |
# Examples
Examples of a *full* CM configuration, including the Repost Rule, in various scenarios. In each scenario the parts of the configuration that affect the rule are indicated.

View File

@@ -1,196 +0,0 @@
# Table of Contents
* [Overview](#overview)
* [Pros And Cons](#pros-and-cons)
* [Technical Overview](#technical-overview)
* [Sentiment Values](#sentiment-values)
* [Usage](#usage)
* [Testing Sentiment Value](#testing-sentiment-value)
* [Numerical](#numerical)
* [Text](#text)
* [Sentiment Rule](#sentiment-rule)
* [Historical](#historical)
* [Examples](#examples)
# Overview
[Sentiment Analysis](https://monkeylearn.com/sentiment-analysis/) (SA) is a form of [Natural Language Processing](https://monkeylearn.com/natural-language-processing/) (NLP) used to extract the overall [sentiment](https://www.merriam-webster.com/dictionary/sentiment) (emotional intent) from a piece of text. Simply, SA is used to determine how positive or negative the emotion of a sentence is.
Examples:
* "I love how curly your hair is" -- very positive
* "The United States is over 200 years old" -- neutral
* "Frankly, your face is disgusting and I would hate to meet you" -- very negative
SA can be a powerful signal for determining the intent of a user's comment/submission. However, it should not be the **only** tool as it comes with both strengths and weaknesses.
## Pros and Cons
Pros
* In terms of Reddit API usage, SA is **free**. It requires no API calls and is computationally trivial.
* Extremely powerful signal for intent since it analyzes the actual text content of an activity
* Requires almost no setup to use
* Can be used as a substitute for regex/keyword matching when looking for hateful/toxic comments
* English language comprehension is very thorough
* Uses 3 independent algorithms to evaluate sentiment
* Understands common english slang, internet slang, and emojis
Cons
* Language limited -- only supported for English (most thorough), French, German, and Spanish
* Less accurate for small word count content (less than 4 words)
* Does not understand sarcasm/jokes
* Accuracy depends on use of common words
* Accuracy depends on clear intent
* Heavy nuance, obscure word choice, and hidden meanings are not understood
## Technical Overview
ContextMod attempts to identify the language of the content it is processing. Based on its confidence of the language it will use up to three different NLP libraries to extract sentiment:
* [NLP.js](https://github.com/axa-group/nlp.js/blob/master/docs/v3/sentiment-analysis.md) (english, french, german, and spanish)
* [vaderSentiment-js](https://github.com/vaderSentiment/vaderSentiment-js/) (english only)
* [wink-sentiment](https://github.com/winkjs/wink-sentiment) (english only)
The above libraries make use of these Sentiment Analysis algorithms:
* VADER https://github.com/cjhutto/vaderSentiment
* AFINN http://corpustext.com/reference/sentiment_afinn.html
* Senticon https://ieeexplore.ieee.org/document/8721408
* Pattern https://github.com/clips/pattern
* wink https://github.com/winkjs/wink-sentiment (modified AFINN with emojis)
Each library produces a normalized score: the sum of all the valence values for each recognized token in its lexicon, divided by the number of words/tokens.
ContextMod takes each normalized score and adjusts it to be between -1 and +1. It then adds finds the average of all normalized score to produce a final sentiment between -1 and +1.
# Sentiment Values
Each piece of content ContextMod analyses produces a score from -1 to +1 to represent the sentiment of that content
| Score | Sentiment |
|-------|--------------------|
| -1 | |
| -0.6 | Extremely Negative |
| -0.3 | Very Negative |
| -0.1 | Negative |
| 0 | Neutral |
| 0.1 | Positive |
| 0.3 | Very Positive |
| 0.6 | Extremely Positive |
| 1 | |
# Usage
## Testing Sentiment Value
Testing for sentiment in the Sentiment Rule is done using either a **text** or **numerical** comparison.
### Numerical
Similar to other numerical comparisons in CM -- use an equality operator and the number to test for:
* `> 0.1` -- sentiment is at least positive
* `<= -0.1` -- sentiment is not negative
Testing for *only* neutral sentiment should be done use a text comparison (below).
### Text
Use any of the **Sentiment** text values from the above table to form a test:
* `is very positive`
* `is neutral`
* `is extremely negative`
You may also use the `not` operator:
* `is not negative`
* `is not very negative`
* `is not neutral`
## Sentiment Rule
An example rule that tests the current comment/submission to see if it has negative sentiment:
```yaml
sentiment: 'is negative'
```
It's very simple :)
### Historical
You may also test the Sentiment of Activities from the user's history. (Note: this may use an API call to get history)
```yaml
sentiment: 'is negative'
historical:
window:
count: 50
mustMatchCurrent: true # optional, the initial activity being tested must test true ("is positive" must be true) before historical tests are run
sentimentVal: 'is very negative' # optional, if the sentiment test to use for historical content is different than the initial test
totalMatching: '> 3' # optional, a comparison for how many historical activities must match sentimentVal
```
# Examples
#### Check with Rules for recent problem subreddit activity and negative sentiment in comment
```yaml
name: Probably Toxic Comment
kind: comment
rules:
- kind: recentActivity
thresholds:
- aProblemSubreddit
- kind: sentiment
name: negsentiment
sentiment: 'is very negative'
actions:
- kind: report
content: 'Sentiment of {{rules.negsentiment.averageScore}} {{rules.negsentiment.sentimentTest}}'
```
#### Check with Rules for recent problem subreddit activity and negative sentiment in comment history from problem subreddits
```yaml
name: Toxic Comment With History
kind: comment
rules:
- kind: recentActivity
thresholds:
- aProblemSubreddit
- aSecondProblemSubreddit
- kind: sentiment
sentiment: 'is very negative'
historical:
sentimentVal: 'is negative'
mustMatchCurrent: true
totalMatching: '> 1'
window:
count: 100
filterOn:
post:
subreddits:
include:
- name:
- aProblemSubreddit
- aSecondProblemSubreddit
actions:
- kind: remove
```
# [Template Variables](/docs/subreddit/actionTemplating.md)
| Name | Description | Example |
|---------------------------|--------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------|
| `result` | Summary of rule results (also found in Actioned Events) | Current Activity Sentiment -0.35 (-0.45) PASSED sentiment test < -0.3 |
| `triggered` | Boolean if rule was triggered or not | true |
| `sentimentTest` | The sentiment value test | `< -0.3` |
| `historicalSentimentTest` | The sentiment value test used for historical activities | `< -0.3` |
| `averageScore` | The averaged score (equal weights) for all sentiment analysis tests run on the current activity | -0.35 |
| `averageWindowScore` | The averaged score (equal weights) for all sentiment analysis tests run on historical activities | -0.35 |
| `window` | Number or duration of Activities considered from window | 100 activities |
| `totalMatching` | Number of activities that passed the sentimentTest | 1 |

View File

@@ -0,0 +1,78 @@
Provided here are **complete, ready-to-go configuration** that can copy-pasted straight into your configuration wiki page to get going with ContextMod immediately.
These configurations attempt to provide sensible, non-destructive, default behavior for some common scenarios and subreddit types.
In most cases these will perform decently out-of-the-box but they are not perfect. You should still monitor bot behavior to see how it performs and will most likely still need to tweak these configurations to get your desired behavior.
All actions for these configurations are non-destructive in that:
* All instances where an activity would be modified (remove/ban/approve) will have `dryRun: true` set to prevent the action from actually being performed
* These instances will also have a `report` action detailing the action would have been performed
**You will have to remove the `report` action and `dryRun` settings yourself.** This is to ensure that you understand the behavior the bot will be performing. If you are unsure of this you should leave them in place until you are certain the behavior the bot is performing is acceptable.
**YAML** is the same format as **automoderator**
## Submission-based Behavior
### Remove submissions from users who have used 'freekarma' subs to bypass karma checks
[YAML](/docs/subreddit/componentscomponents/subredditReady/freekarma.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/freekarma.json5)
If the user has any activity (comment/submission) in known freekarma subreddits in the past (50 activities or 6 months) then remove the submission.
### Remove submissions from users who have crossposted the same submission 4 or more times
[YAML](/docs/subreddit/componentscomponents/subredditReady/crosspostSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/crosspostSpam.yaml)
If the user has crossposted the same submission in the past (50 activities or 6 months) 4 or more times in a row then remove the submission.
### Remove submissions from users who have crossposted or used 'freekarma' subs
[YAML](/docs/subreddit/componentscomponents/subredditReady/freeKarmaOrCrosspostSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/freeKarmaOrCrosspostSpam.json5)
Will remove submission if either of the above two behaviors is detected
### Remove link submissions where the user's history is comprised of 10% or more of the same link
[YAML](/docs/subreddit/componentscomponents/subredditReady/selfPromo.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/selfPromo.json5)
If the link origin (youtube author, twitter author, etc. or regular domain for non-media links)
* comprises 10% or more of the users **entire** history in the past (100 activities or 6 months)
* or comprises 10% or more of the users **submission** history in the past (100 activities or 6 months) and the user has low engagement (<50% of history is comments or 40%> of comment are as OP)
then remove the submission
## Comment-based behavior
### Remove comment if the user has posted the same comment 4 or more times in a row
[YAML](/docs/subreddit/componentscomponents/subredditReady/commentSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/commentSpam.json5)
If the user made the same comment (with some fuzzy matching) 4 or more times in a row in the past (50 activities or 6 months) then remove the comment.
### Remove comment if it is discord invite link spam
[YAML](/docs/subreddit/componentscomponents/subredditReady/discordSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/discordSpam.json5)
This rule goes a step further than automod can by being more discretionary about how it handles this type of spam.
* Remove the comment and **ban a user** if:
* Comment being checked contains **only** a discord link (no other text) AND
* Discord links appear **anywhere** in three or more of the last 10 comments the Author has made
otherwise...
* Remove the comment if:
* Comment being checked contains **only** a discord link (no other text) OR
* Comment contains a discord link **anywhere** AND
* Discord links appear **anywhere** in three or more of the last 10 comments the Author has made
Using these checks ContextMod can more easily distinguish between these use cases for a user commenting with a discord link:
* actual spammers who only spam a discord link
* users who may comment with a link but have context for it either in the current comment or in their history
* users who many comment with a link but it's a one-off event (no other links historically)
Additionally, you could modify both/either of these checks to not remove one-off discord link comments but still remove if the user has a historical trend for spamming links

View File

@@ -0,0 +1,46 @@
{
"polling": ["newComm"],
"runs": [
{
"checks": [
{
//
// Stop users who spam the same comment many times
//
// Remove a COMMENT if the user has crossposted it at least 4 times in recent history
//
"name": "low xp comment spam",
"description": "X-posted comment >=4x",
"kind": "comment",
"condition": "AND",
"rules": [
{
"name": "xPostLow",
"kind": "repeatActivity",
"gapAllowance": 2,
"threshold": ">= 4",
"window": {
"count": 50,
"duration": "6 months"
}
},
],
"actions": [
// remove this after confirming behavior is acceptable
{
"kind": "report",
"content": "Remove=> Posted same comment {{rules.xpostlow.largestRepeat}}x times"
},
//
//
{
"kind": "remove",
// remove the line below after confirming behavior is acceptable
"dryRun": true
}
]
}
]
}
],
}

View File

@@ -1,18 +1,14 @@
#polling:
# - newComm
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Remove comments by users who spam the same comment many times
#
polling:
- newComm
runs:
- checks:
# Stop users who spam the same comment many times
- name: low xp comment spam
description: X-posted comment >=4x
kind: comment
condition: AND
rules:
- name: xPostLowComm
- name: xPostLow
kind: repeatActivity
# number of "non-repeat" comments allowed between "repeat comments"
gapAllowance: 2
@@ -20,13 +16,11 @@
threshold: '>= 4'
# retrieve either last 50 comments or 6 months' of history, whichever is less
window:
count: 100
count: 50
duration: 6 months
actions:
- kind: report
enable: false
content: 'Remove => Posted same comment {{rules.xpostlowcomm.largestRepeat}}x times'
enable: true
content: 'Remove => Posted same comment {{rules.xpostlow.largestRepeat}}x times'
- kind: remove
enable: true
note: 'Posted same comment {{rules.xpostlowcomm.largestRepeat}}x times'

View File

@@ -0,0 +1,81 @@
{
"polling": ["unmoderated"],
"runs": [
{
"checks": [
{
//
// Stop users who post low-effort, crossposted spam
//
// Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
// less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
//
"name": "low xp spam and engagement",
"description": "X-posted 4x and low comment engagement",
"kind": "submission",
"itemIs": [
{
"removed": false
}
],
"condition": "AND",
"rules": [
{
"name": "xPostLow",
"kind": "repeatActivity",
"gapAllowance": 2,
"threshold": ">= 4",
"window": {
"count": 50,
"duration": "6 months"
}
},
{
"name": "lowOrOpComm",
"kind": "history",
"criteriaJoin": "OR",
"criteria": [
{
"window": {
"count": 100,
"duration": "6 months"
},
"comment": "< 50%"
},
{
"window": {
"count": 100,
"duration": "6 months"
},
"comment": "> 40% OP"
}
]
}
],
"actions": [
// remove this after confirming behavior is acceptable
{
"kind": "report",
"content": "Remove=>{{rules.xpostlow.largestRepeat}} X-P => {{rules.loworopcomm.thresholdSummary}}"
},
//
//
{
"kind": "remove",
// remove the line below after confirming behavior is acceptable
"dryRun": true
},
// optionally remove "dryRun" from below if you want to leave a comment on removal
// PROTIP: the comment is bland, you should make it better
{
"kind": "comment",
"content": "Your submission has been removed because you cross-posted it {{rules.xpostlow.largestRepeat}} times and you have very low engagement outside of making submissions",
"distinguish": true,
"dryRun": true
}
]
}
]
}
],
}

View File

@@ -1,11 +1,7 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
polling:
- unmoderated
runs:
- checks:
# stop users who post low-effort, crossposted spam submissions
#
# Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
@@ -22,7 +18,7 @@
gapAllowance: 2
threshold: '>= 4'
window:
count: 100
count: 50
duration: 6 months
- name: lowOrOpComm
kind: history
@@ -38,15 +34,12 @@
comment: '> 40% OP'
actions:
- kind: report
enable: false
enable: true
content: >-
Remove=>{{rules.xpostlow.largestRepeat}} X-P =>
{{rules.loworopcomm.thresholdSummary}}
- kind: remove
enable: true
note: 'Repeated submission {{rules.xpostlow.largestRepeat}}x and low comment engagement'
- kind: comment
enable: true
content: >-

View File

@@ -0,0 +1,79 @@
{
"polling": ["newComm"],
"runs": [
{
"checks": [
{
"name": "ban discord only spammer",
"description": "ban a user who spams only a discord link many times historically",
"kind": "comment",
"condition": "AND",
"rules": [
"linkOnlySpam",
"linkAnywhereHistoricalSpam",
],
"actions": [
{
"kind": "remove"
},
{
"kind": "ban",
"content": "spamming discord links"
}
]
},
{
"name": "remove discord spam",
"description": "remove comments from users who only link to discord or mention discord link many times historically",
"kind": "comment",
"condition": "OR",
"rules": [
{
"name": "linkOnlySpam",
"kind": "regex",
"criteria": [
{
"name": "only link",
"regex": "/^.*(discord\\.gg\\/[\\w\\d]+)$/i",
}
]
},
{
"condition": "AND",
"rules": [
{
"name": "linkAnywhereSpam",
"kind": "regex",
"criteria": [
{
"name": "contains link anywhere",
"regex": "/^.*(discord\\.gg\\/[\\w\\d]+).*$/i",
}
]
},
{
"name": "linkAnywhereHistoricalSpam",
"kind": "regex",
"criteria": [
{
"name": "contains links anywhere historically",
"regex": "/^.*(discord\\.gg\\/[\\w\\d]+).*$/i",
"totalMatchThreshold": ">= 3",
"lookAt": "comments",
"window": 10
}
]
}
]
}
],
"actions": [
{
"kind": "remove"
}
]
}
]
}
],
}

View File

@@ -1,18 +1,9 @@
#polling:
# - newComm
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
#
# Remove comments from users who spam discord and telegram links
# -- differs from just using automod:
# 1) removes comment if it is ONLY discord/telegram link
# 2) if not *only* link then checks user's history to see if link is spammed many times and only removes if it is
#
- name: ban chat only spammer
description: ban a user who spams only a chat link many times historically
polling:
- newComm
runs:
- checks:
- name: ban discord only spammer
description: ban a user who spams only a discord link many times historically
kind: comment
condition: AND
rules:
@@ -22,9 +13,9 @@
- kind: remove
- kind: ban
content: spamming discord links
- name: remove chat spam
- name: remove discord spam
description: >-
remove comments from users who only link to chat or mention chat
remove comments from users who only link to discord or mention discord
link many times historically
kind: comment
condition: OR
@@ -33,9 +24,8 @@
kind: regex
criteria:
- name: only link
# https://regexr.com/70j9m
# single quotes are required to escape special characters
regex: '/^\s*((?:discord\.gg|t\.me|telegram\.me|telegr\.im)\/[\w\d]+)\s*$/i'
regex: '/^.*(discord\.gg\/[\w\d]+)$/i'
- condition: AND
rules:
- name: linkAnywhereSpam
@@ -43,16 +33,15 @@
criteria:
- name: contains link anywhere
# single quotes are required to escape special characters
regex: '/((?:discord\.gg|t\.me|telegram\.me|telegr\.im)\/[\w\d]+)/i'
regex: '/^.*(discord\.gg\/[\w\d]+).*$/i'
- name: linkAnywhereHistoricalSpam
kind: regex
criteria:
- name: contains links anywhere historically
# single quotes are required to escape special characters
regex: '/((?:discord\.gg|t\.me|telegram\.me|telegr\.im)\/[\w\d]+)/i'
regex: '/^.*(discord\.gg\/[\w\d]+).*$/i'
totalMatchThreshold: '>= 3'
lookAt: comments
window: 100
window: 10
actions:
- kind: remove
note: Chat spam link

View File

@@ -0,0 +1,142 @@
{
"polling": [
"unmoderated"
],
"runs": [
{
"checks": [
{
//
// Stop users who post low-effort, crossposted spam
//
// Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
// less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
//
"name": "remove on low xp spam and engagement",
"description": "X-posted 4x and low comment engagement",
"kind": "submission",
"itemIs": [
{
"removed": false
}
],
"condition": "AND",
"rules": [
{
"name": "xPostLow",
"kind": "repeatActivity",
"gapAllowance": 2,
"threshold": ">= 4",
"window": {
"count": 50,
"duration": "6 months"
}
},
{
"name": "lowOrOpComm",
"kind": "history",
"criteriaJoin": "OR",
"criteria": [
{
"window": {
"count": 100,
"duration": "6 months"
},
"comment": "< 50%"
},
{
"window": {
"count": 100,
"duration": "6 months"
},
"comment": "> 40% OP"
}
]
}
],
"actions": [
// remove this after confirming behavior is acceptable
{
"kind": "report",
"content": "Remove=>{{rules.xpostlow.largestRepeat}} X-P => {{rules.loworopcomm.thresholdSummary}}"
},
//
//
{
"kind": "remove",
// remove the line below after confirming behavior is acceptable
"dryRun": true
},
// optionally remove "dryRun" from below if you want to leave a comment on removal
// PROTIP: the comment is bland, you should make it better
{
"kind": "comment",
"content": "Your submission has been removed because you cross-posted it {{rules.xpostlow.largestRepeat}} times and you have very low engagement outside of making submissions",
"distinguish": true,
"dryRun": true
}
]
},
{
//
// Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
//
"name": "freekarma removal",
"description": "Remove submission if user has used freekarma sub recently",
"kind": "submission",
"itemIs": [
{
"removed": false
}
],
"condition": "AND",
"rules": [
{
"name": "freekarma",
"kind": "recentActivity",
"window": {
"count": 50,
"duration": "6 months"
},
"useSubmissionAsReference": false,
"thresholds": [
{
"subreddits": [
"FreeKarma4U",
"FreeKarma4You",
"KarmaStore",
"promote",
"shamelessplug",
"upvote"
]
}
]
}
],
"actions": [
// remove this after confirming behavior is acceptable
{
"kind": "report",
"content": "Remove=> {{rules.newtube.totalCount}} activities in freekarma subs"
},
//
//
{
"kind": "remove",
// remove the line below after confirming behavior is acceptable
"dryRun": true
},
// optionally remove "dryRun" from below if you want to leave a comment on removal
// PROTIP: the comment is bland, you should make it better
{
"kind": "comment",
"content": "Your submission has been removed because you have recent activity in 'freekarma' subs",
"distinguish": true,
"dryRun": true
}
]
}
]
}
]
}

View File

@@ -0,0 +1,85 @@
polling:
- unmoderated
runs:
- checks:
# stop users who post low-effort, crossposted spam submissions
#
# Remove a SUBMISSION if the user has crossposted it at least 4 times in recent history AND
# less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
- name: remove on low xp spam and engagement
description: X-posted 4x and low comment engagement
kind: submission
itemIs:
- removed: false
condition: AND
rules:
- name: xPostLow
kind: repeatActivity
gapAllowance: 2
threshold: '>= 4'
window:
count: 50
duration: 6 months
- name: lowOrOpComm
kind: history
criteriaJoin: OR
criteria:
- window:
count: 100
duration: 6 months
comment: < 50%
- window:
count: 100
duration: 6 months
comment: '> 40% OP'
actions:
- kind: report
enable: true
content: >-
Remove=>{{rules.xpostlow.largestRepeat}} X-P =>
{{rules.loworopcomm.thresholdSummary}}
- kind: remove
enable: false
- kind: comment
enable: true
content: >-
Your submission has been removed because you cross-posted it
{{rules.xpostlow.largestRepeat}} times and you have very low
engagement outside of making submissions
distinguish: true
dryRun: true
# Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
- name: freekarma removal
description: Remove submission if user has used freekarma sub recently
kind: submission
itemIs:
- removed: false
condition: AND
rules:
- name: freekarma
kind: recentActivity
window:
count: 50
duration: 6 months
useSubmissionAsReference: false
thresholds:
- subreddits:
- FreeKarma4U
- FreeKarma4You
- KarmaStore
- promote
- shamelessplug
- upvote
actions:
- kind: report
enable: true
content: 'Remove=> {{rules.newtube.totalCount}} activities in freekarma subs'
- kind: remove
enable: false
- kind: comment
enable: true
content: >-
Your submission has been removed because you have recent activity in
'freekarma' subs
distinguish: true
dryRun: true

View File

@@ -0,0 +1,68 @@
{
"polling": [
"unmoderated"
],
"runs": [
{
"checks": [
{
//
// Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
//
"name": "freekarma removal",
"description": "Remove submission if user has used freekarma sub recently",
"kind": "submission",
"itemIs": [
{
"removed": false
}
],
"condition": "AND",
"rules": [
{
"name": "freekarma",
"kind": "recentActivity",
"window": {
"count": 50,
"duration": "6 months"
},
"useSubmissionAsReference": false,
"thresholds": [
{
"subreddits": [
"FreeKarma4U",
"FreeKarma4You",
"KarmaStore",
"upvote"
]
}
]
}
],
"actions": [
// remove this after confirming behavior is acceptable
{
"kind": "report",
"content": "Remove=> {{rules.newtube.totalCount}} activities in freekarma subs"
},
//
//
{
"kind": "remove",
// remove the line below after confirming behavior is acceptable
"dryRun": true,
},
// optionally remove "dryRun" from below if you want to leave a comment on removal
// PROTIP: the comment is bland, you should make it better
{
"kind": "comment",
"content": "Your submission has been removed because you have recent activity in 'freekarma' subs",
"distinguish": true,
"dryRun": true,
}
]
}
]
}
],
}

View File

@@ -0,0 +1,36 @@
polling:
- unmoderated
runs:
- checks:
# Remove submissions from users who have recent activity in freekarma subs within the last 50 activities or 6 months (whichever is less)
- name: freekarma removal
description: Remove submission if user has used freekarma sub recently
kind: submission
itemIs:
- removed: false
condition: AND
rules:
- name: freekarma
kind: recentActivity
window:
count: 50
duration: 6 months
useSubmissionAsReference: false
thresholds:
- subreddits:
- FreeKarma4U
- FreeKarma4You
- KarmaStore
- upvote
actions:
- kind: report
enable: true
content: 'Remove=> {{rules.newtube.totalCount}} activities in freekarma subs'
- kind: remove
enable: true
- kind: comment
enable: false
content: >-
Your submission has been removed because you have recent activity in
'freekarma' subs
distinguish: true

View File

@@ -0,0 +1,108 @@
{
"polling": [
"unmoderated"
],
"runs": [
{
"checks": [
{
//
// Stop users who make link submissions with a self-promotional agenda (with reddit's suggested 10% rule)
// https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit
//
// Remove a SUBMISSION if the link comprises more than or equal to 10% of users history (100 activities or 6 months) OR
//
// if link comprises 10% of submission history (100 activities or 6 months)
// AND less than 50% of their activity is comments OR more than 40% of those comments are as OP (in the own submissions)
//
"name": "Self-promo all AND low engagement",
"description": "Self-promo is >10% for all or just sub and low comment engagement",
"kind": "submission",
"condition": "OR",
"rules": [
{
"name": "attr",
"kind": "attribution",
"criteria": [
{
"threshold": ">= 10%",
"window": {
"count": 100,
"duration": "6 months"
},
"domains": [
"AGG:SELF"
]
}
],
},
{
"condition": "AND",
"rules": [
{
"name": "attrsub",
"kind": "attribution",
"criteria": [
{
"threshold": ">= 10%",
"thresholdOn": "submissions",
"window": {
"count": 100,
"duration": "6 months"
},
"domains": [
"AGG:SELF"
]
}
]
},
{
"name": "lowOrOpComm",
"kind": "history",
"criteriaJoin": "OR",
"criteria": [
{
"window": {
"count": 100,
"duration": "6 months"
},
"comment": "< 50%"
},
{
"window": {
"count": 100,
"duration": "6 months"
},
"comment": "> 40% OP"
}
]
}
]
}
],
"actions": [
{
"kind": "report",
"content": "{{rules.attr.largestPercent}}{{rules.attrsub.largestPercent}} of {{rules.attr.activityTotal}}{{rules.attrsub.activityTotal}} items ({{rules.attr.window}}{{rules.attrsub.window}}){{#rules.loworopcomm.thresholdSummary}} => {{rules.loworopcomm.thresholdSummary}}{{/rules.loworopcomm.thresholdSummary}}"
},
//
//
{
"kind": "remove",
// remove the line below after confirming behavior is acceptable
"dryRun": true
},
// optionally remove "dryRun" from below if you want to leave a comment on removal
// PROTIP: the comment is bland, you should make it better
{
"kind": "comment",
"content": "Your submission has been removed it comprises 10% or more of your recent history ({{rules.attr.largestPercent}}{{rules.attrsub.largestPercent}}). This is against [reddit's self promotional guidelines.](https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit)",
"distinguish": true,
"dryRun": true
}
]
}
]
}
]
}

View File

@@ -1,10 +1,7 @@
#polling:
# - newSub
#runs:
# - checks:
#### Uncomment the code above to use this as a FULL subreddit config
####
#### Otherwise copy-paste the code below to use as a CHECK
polling:
- unmoderated
runs:
- checks:
#
# Stop users who make link submissions with a self-promotional agenda (with reddit's suggested 10% rule)
# https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit
@@ -61,11 +58,8 @@
({{rules.attr.window}}{{rules.attrsub.window}}){{#rules.loworopcomm.thresholdSummary}}
=>
{{rules.loworopcomm.thresholdSummary}}{{/rules.loworopcomm.thresholdSummary}}
- kind: remove
enable: true
note: '>10% of author's history is content from this creator'
enable: false
- kind: comment
enable: true
content: >-
@@ -75,3 +69,4 @@
is against [reddit's self promotional
guidelines.](https://www.reddit.com/wiki/selfpromotion#wiki_guidelines_for_self-promotion_on_reddit)
distinguish: true
dryRun: true

View File

@@ -6,16 +6,6 @@ Context Mod supports reading and writing [User Notes](https://www.reddit.com/r/t
[Click here for the Toolbox Quickstart Guide](https://www.reddit.com/r/toolbox/wiki/docs/quick_start)
Valid Note Types:
* `gooduser`
* `spamwatch`
* `spamwarn`
* `abusewarn`
* `ban`
* `permban`
* `botban`
## Filter
User Notes are an additional criteria on [AuthorCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) that can be used alongside other Author properties for both [filtering rules and in the AuthorRule.](/docs/subreddit/components/author/)
@@ -24,7 +14,7 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteCr
### Examples
* Do not tag user with Good User note [JSON](/docs/subreddit/components/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteFilter.yaml)
* Do not tag user with Good User note [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.yaml)
## Action
@@ -33,4 +23,4 @@ A User Note can also be added to the Author of a Submission or Comment with the
### Examples
* Add note on user doing self promotion [JSON](/docs/subreddit/components/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteSP.yaml)
* Add note on user doing self promotion [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteSP.yaml)

View File

@@ -104,7 +104,7 @@ If you already have a configuration you may skip the below step and go directly
### Using an Example Config
Visit the [Examples](https://github.com/FoxxMD/context-mod/tree/master/docs/examples) folder to find various examples of individual rules or see the [subreddit cookbook examples.](/docs/subreddit/components/cookbook)
Visit the [Examples](https://github.com/FoxxMD/context-mod/tree/master/docs/examples) folder to find various examples of individual rules or see the [subreddit-ready examples.](/docs/subreddit/components/subredditReady)
After you have found a configuration to use as a starting point:

View File

@@ -20,7 +20,6 @@
## Web Dashboard Tips
* Click the **Help** button at the top of the page to get a **guided tour of the dashboard**
* Use the [**Overview** section](/docs/images/botOperations.png) to control the bot at a high-level
* You can **manually run** the bot on any activity (comment/submission) by pasting its permalink into the [input field below the Overview section](/docs/images/runInput.png) and hitting one of the **run buttons**
* **Dry run** will make the bot run on the activity but it will only **pretend** to run actions, if triggered. This is super useful for testing your config without consequences

123
package-lock.json generated
View File

@@ -1,20 +1,17 @@
{
"name": "redditcontextbot",
"version": "0.11.4",
"version": "0.5.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "redditcontextbot",
"version": "0.11.4",
"version": "0.5.1",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@awaitjs/express": "^0.8.0",
"@datasert/cronjs-matcher": "^1.2.0",
"@googleapis/youtube": "^2.0.0",
"@influxdata/influxdb-client": "^1.31.0",
"@influxdata/influxdb-client-apis": "^1.31.0",
"@nlpjs/core": "^4.23.4",
"@nlpjs/lang-de": "^4.23.4",
"@nlpjs/lang-en": "^4.23.4",
@@ -32,7 +29,7 @@
"cache-manager-redis-store": "^2.0.0",
"commander": "^8.0.0",
"comment-json": "^4.1.1",
"connect-typeorm": "^2.0.0",
"connect-typeorm": "github:FoxxMD/connect-typeorm#typeormBump",
"cookie-parser": "^1.3.5",
"dayjs": "^1.10.5",
"deepmerge": "^4.2.2",
@@ -76,9 +73,8 @@
"string-similarity": "^4.0.4",
"tcp-port-used": "^1.0.2",
"triple-beam": "^1.3.0",
"typeorm": "^0.3.7",
"typeorm": "^0.3.4",
"typeorm-logger-adaptor": "^1.1.0",
"unique-names-generator": "^4.7.1",
"vader-sentiment": "^1.1.3",
"webhook-discord": "^3.7.7",
"wink-sentiment": "^5.0.2",
@@ -657,20 +653,6 @@
"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",
@@ -682,19 +664,6 @@
"node": ">=10.0.0"
}
},
"node_modules/@influxdata/influxdb-client": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz",
"integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA=="
},
"node_modules/@influxdata/influxdb-client-apis": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz",
"integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==",
"peerDependencies": {
"@influxdata/influxdb-client": "*"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -2817,9 +2786,9 @@
}
},
"node_modules/connect-typeorm": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-typeorm/-/connect-typeorm-2.0.0.tgz",
"integrity": "sha512-0OcbHJkNMTJjSrbcKGljr4PKgRq13Dds7zQq3+8oaf4syQTgGvGv9OgnXo2qg+Bljkh4aJNzIvW74QOVLn8zrw==",
"version": "1.2.0",
"resolved": "git+ssh://git@github.com/FoxxMD/connect-typeorm.git#a2f0a7225a1c218db0b919142076ce0774c8caa6",
"license": "MIT",
"dependencies": {
"@types/debug": "0.0.31",
"@types/express-session": "^1.15.5",
@@ -2827,7 +2796,7 @@
"express-session": "^1.15.6"
},
"peerDependencies": {
"typeorm": "^0.3.0"
"typeorm": "^0.3.4"
}
},
"node_modules/connect-typeorm/node_modules/debug": {
@@ -5811,14 +5780,6 @@
"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",
@@ -9339,9 +9300,9 @@
}
},
"node_modules/typeorm": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.7.tgz",
"integrity": "sha512-MsPJeP6Zuwfe64c++l80+VRqpGEGxf0CkztIEnehQ+CMmQPSHjOnFbFxwBuZ2jiLqZTjLk2ZqQdVF0RmvxNF3Q==",
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.6.tgz",
"integrity": "sha512-DRqgfqcelMiGgWSMbBmVoJNFN2nPNA3EeY2gC324ndr2DZoGRTb9ILtp2oGVGnlA+cu5zgQ6it5oqKFNkte7Aw==",
"dependencies": {
"@sqltools/formatter": "^1.2.2",
"app-root-path": "^3.0.0",
@@ -9374,12 +9335,12 @@
},
"peerDependencies": {
"@google-cloud/spanner": "^5.18.0",
"@sap/hana-client": "^2.12.25",
"@sap/hana-client": "^2.11.14",
"better-sqlite3": "^7.1.2",
"hdb-pool": "^0.1.6",
"ioredis": "^5.0.4",
"ioredis": "^4.28.3",
"mongodb": "^3.6.0",
"mssql": "^7.3.0",
"mssql": "^6.3.1",
"mysql2": "^2.2.5",
"oracledb": "^5.1.0",
"pg": "^8.5.1",
@@ -9387,7 +9348,7 @@
"pg-query-stream": "^4.0.0",
"redis": "^3.1.1 || ^4.0.0",
"sql.js": "^1.4.0",
"sqlite3": "^5.0.3",
"sqlite3": "^5.0.2",
"ts-node": "^10.7.0",
"typeorm-aurora-data-api-driver": "^2.0.0"
},
@@ -9765,14 +9726,6 @@
"node": ">= 0.8.x"
}
},
"node_modules/unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow==",
"engines": {
"node": ">=8"
}
},
"node_modules/universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",
@@ -10835,20 +10788,6 @@
"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",
@@ -10857,17 +10796,6 @@
"googleapis-common": "^5.0.1"
}
},
"@influxdata/influxdb-client": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz",
"integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA=="
},
"@influxdata/influxdb-client-apis": {
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz",
"integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==",
"requires": {}
},
"@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -12621,9 +12549,8 @@
}
},
"connect-typeorm": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/connect-typeorm/-/connect-typeorm-2.0.0.tgz",
"integrity": "sha512-0OcbHJkNMTJjSrbcKGljr4PKgRq13Dds7zQq3+8oaf4syQTgGvGv9OgnXo2qg+Bljkh4aJNzIvW74QOVLn8zrw==",
"version": "git+ssh://git@github.com/FoxxMD/connect-typeorm.git#a2f0a7225a1c218db0b919142076ce0774c8caa6",
"from": "connect-typeorm@github:FoxxMD/connect-typeorm#typeormBump",
"requires": {
"@types/debug": "0.0.31",
"@types/express-session": "^1.15.5",
@@ -14923,11 +14850,6 @@
"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",
@@ -17635,9 +17557,9 @@
}
},
"typeorm": {
"version": "0.3.7",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.7.tgz",
"integrity": "sha512-MsPJeP6Zuwfe64c++l80+VRqpGEGxf0CkztIEnehQ+CMmQPSHjOnFbFxwBuZ2jiLqZTjLk2ZqQdVF0RmvxNF3Q==",
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-0.3.6.tgz",
"integrity": "sha512-DRqgfqcelMiGgWSMbBmVoJNFN2nPNA3EeY2gC324ndr2DZoGRTb9ILtp2oGVGnlA+cu5zgQ6it5oqKFNkte7Aw==",
"requires": {
"@sqltools/formatter": "^1.2.2",
"app-root-path": "^3.0.0",
@@ -17888,11 +17810,6 @@
"resolved": "https://registry.npmjs.org/unicode/-/unicode-14.0.0.tgz",
"integrity": "sha512-BjinxTXkbm9Jomp/YBTMGusr4fxIG67fNGShHIRAL16Ur2GJTq2xvLi+sxuiJmInCmwqqev2BCFKyvbfp/yAkg=="
},
"unique-names-generator": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/unique-names-generator/-/unique-names-generator-4.7.1.tgz",
"integrity": "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="
},
"universalify": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "redditcontextbot",
"version": "0.11.4",
"version": "0.5.1",
"description": "",
"main": "index.js",
"scripts": {
@@ -8,15 +8,13 @@
"build": "tsc && npm run bundle-front",
"bundle-front": "browserify src/Web/assets/browser.js | terser --compress --mangle > src/Web/assets/public/browserBundle.js",
"start": "node src/index.js run",
"schema": "npm run -s schema-app & npm run -s schema-ruleset & npm run -s schema-rule & npm run -s schema-action & npm run -s schema-check & npm run -s schema-run & npm run -s schema-config",
"schema-app": "typescript-json-schema tsconfig.json SubredditConfigData --out src/Schema/App.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-ruleset": "typescript-json-schema tsconfig.json RuleSetConfigData --out src/Schema/RuleSet.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-rule": "typescript-json-schema tsconfig.json RuleConfigData --out src/Schema/Rule.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-check": "typescript-json-schema tsconfig.json ActivityCheckConfigValue --out src/Schema/Check.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-run": "typescript-json-schema tsconfig.json RunConfigValue --out src/Schema/Run.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-action": "typescript-json-schema tsconfig.json ActionConfigData --out src/Schema/Action.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema": "npm run -s schema-app & npm run -s schema-ruleset & npm run -s schema-rule & npm run -s schema-action & npm run -s schema-config",
"schema-app": "typescript-json-schema tsconfig.json JSONConfig --out src/Schema/App.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-ruleset": "typescript-json-schema tsconfig.json RuleSetJson --out src/Schema/RuleSet.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-rule": "typescript-json-schema tsconfig.json RuleJson --out src/Schema/Rule.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-action": "typescript-json-schema tsconfig.json ActionJson --out src/Schema/Action.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schema-config": "typescript-json-schema tsconfig.json OperatorJsonConfig --out src/Schema/OperatorConfig.json --required --tsNodeRegister --refs --validationKeywords deprecationMessage",
"schemaNotWorking": "./node_modules/.bin/ts-json-schema-generator -f tsconfig.json -p src/SubredditConfigData.ts -t JSONConfig --out src/Schema/vegaSchema.json",
"schemaNotWorking": "./node_modules/.bin/ts-json-schema-generator -f tsconfig.json -p src/JsonConfig.ts -t JSONConfig --out src/Schema/vegaSchema.json",
"circular": "madge --circular --extensions ts src/index.ts",
"circular-graph": "madge --image graph.svg --circular --extensions ts src/index.ts",
"postinstall": "patch-package",
@@ -31,10 +29,7 @@
"license": "ISC",
"dependencies": {
"@awaitjs/express": "^0.8.0",
"@datasert/cronjs-matcher": "^1.2.0",
"@googleapis/youtube": "^2.0.0",
"@influxdata/influxdb-client": "^1.31.0",
"@influxdata/influxdb-client-apis": "^1.31.0",
"@nlpjs/core": "^4.23.4",
"@nlpjs/lang-de": "^4.23.4",
"@nlpjs/lang-en": "^4.23.4",
@@ -52,7 +47,7 @@
"cache-manager-redis-store": "^2.0.0",
"commander": "^8.0.0",
"comment-json": "^4.1.1",
"connect-typeorm": "^2.0.0",
"connect-typeorm": "github:FoxxMD/connect-typeorm#typeormBump",
"cookie-parser": "^1.3.5",
"dayjs": "^1.10.5",
"deepmerge": "^4.2.2",
@@ -96,9 +91,8 @@
"string-similarity": "^4.0.4",
"tcp-port-used": "^1.0.2",
"triple-beam": "^1.3.0",
"typeorm": "^0.3.7",
"typeorm": "^0.3.4",
"typeorm-logger-adaptor": "^1.1.0",
"unique-names-generator": "^4.7.1",
"vader-sentiment": "^1.1.3",
"webhook-discord": "^3.7.7",
"wink-sentiment": "^5.0.2",

View File

@@ -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, ActionRuntimeOptions, StructuredActionJson} from "./index";
import Action, {ActionJson, StructuredActionJson} from "./index";
import {Logger} from "winston";
import {UserNoteAction, UserNoteActionJson} from "./UserNoteAction";
import ApproveAction, {ApproveActionConfig} from "./ApproveAction";
@@ -17,42 +17,36 @@ import {DispatchAction, DispatchActionJson} from "./DispatchAction";
import {CancelDispatchAction, CancelDispatchActionJson} from "./CancelDispatchAction";
import ContributorAction, {ContributorActionJson} from "./ContributorAction";
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
import {ModNoteAction, ModNoteActionJson} from "./ModNoteAction";
import {SubmissionAction, SubmissionActionJson} from "./SubmissionAction";
export function actionFactory
(config: StructuredActionJson, runtimeOptions: ActionRuntimeOptions): Action {
(config: StructuredActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap, emitter: EventEmitter): Action {
switch (config.kind) {
case 'comment':
return new CommentAction({...config as StructuredFilter<CommentActionJson>, ...runtimeOptions});
case 'submission':
return new SubmissionAction({...config as StructuredFilter<SubmissionActionJson>, ...runtimeOptions});
return new CommentAction({...config as StructuredFilter<CommentActionJson>, logger, subredditName, resources, client, emitter});
case 'lock':
return new LockAction({...config as StructuredFilter<LockActionJson>, ...runtimeOptions});
return new LockAction({...config as StructuredFilter<LockActionJson>, logger, subredditName, resources, client, emitter});
case 'remove':
return new RemoveAction({...config as StructuredFilter<RemoveActionJson>, ...runtimeOptions});
return new RemoveAction({...config as StructuredFilter<RemoveActionJson>, logger, subredditName, resources, client, emitter});
case 'report':
return new ReportAction({...config as StructuredFilter<ReportActionJson>, ...runtimeOptions});
return new ReportAction({...config as StructuredFilter<ReportActionJson>, logger, subredditName, resources, client, emitter});
case 'flair':
return new FlairAction({...config as StructuredFilter<FlairActionJson>, ...runtimeOptions});
return new FlairAction({...config as StructuredFilter<FlairActionJson>, logger, subredditName, resources, client, emitter});
case 'userflair':
return new UserFlairAction({...config as StructuredFilter<UserFlairActionJson>, ...runtimeOptions});
return new UserFlairAction({...config as StructuredFilter<UserFlairActionJson>, logger, subredditName, resources, client, emitter});
case 'approve':
return new ApproveAction({...config as StructuredFilter<ApproveActionConfig>, ...runtimeOptions});
return new ApproveAction({...config as StructuredFilter<ApproveActionConfig>, logger, subredditName, resources, client, emitter});
case 'usernote':
return new UserNoteAction({...config as StructuredFilter<UserNoteActionJson>, ...runtimeOptions});
return new UserNoteAction({...config as StructuredFilter<UserNoteActionJson>, logger, subredditName, resources, client, emitter});
case 'ban':
return new BanAction({...config as StructuredFilter<BanActionJson>, ...runtimeOptions});
return new BanAction({...config as StructuredFilter<BanActionJson>, logger, subredditName, resources, client, emitter});
case 'message':
return new MessageAction({...config as StructuredFilter<MessageActionJson>, ...runtimeOptions});
return new MessageAction({...config as StructuredFilter<MessageActionJson>, logger, subredditName, resources, client, emitter});
case 'dispatch':
return new DispatchAction({...config as StructuredFilter<DispatchActionJson>, ...runtimeOptions});
return new DispatchAction({...config as StructuredFilter<DispatchActionJson>, logger, subredditName, resources, client, emitter});
case 'cancelDispatch':
return new CancelDispatchAction({...config as StructuredFilter<CancelDispatchActionJson>, ...runtimeOptions})
return new CancelDispatchAction({...config as StructuredFilter<CancelDispatchActionJson>, logger, subredditName, resources, client, emitter})
case 'contributor':
return new ContributorAction({...config as StructuredFilter<ContributorActionJson>, ...runtimeOptions})
case 'modnote':
return new ModNoteAction({...config as StructuredFilter<ModNoteActionJson>, ...runtimeOptions})
return new ContributorAction({...config as StructuredFilter<ContributorActionJson>, logger, subredditName, resources, client, emitter})
default:
throw new Error('rule "kind" was not recognized.');
}

View File

@@ -8,7 +8,6 @@ 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 {
@@ -27,7 +26,7 @@ export class ApproveAction extends Action {
this.targets = targets;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const touchedEntities = [];

View File

@@ -6,18 +6,6 @@ import {ActionProcessResult, Footer, RuleResult} from "../Common/interfaces";
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 {
@@ -47,19 +35,17 @@ export class BanAction extends Action {
return 'ban';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
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 = 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 content = this.message === undefined ? undefined : await this.resources.getContent(this.message, item.subreddit);
const renderedBody = content === undefined ? undefined : await renderContent(content, item, ruleResults, this.resources.userNotes);
const renderedContent = renderedBody === undefined ? undefined : `${renderedBody}${await this.resources.generateFooter(item, this.footer)}`;
const touchedEntities = [];
let banPieces = [];
banPieces.push(`Message: ${renderedContent === undefined ? 'None' : `${renderedContent.length > 100 ? `\r\n${truncateLongMessage(renderedContent)}` : renderedContent}`}`);
banPieces.push(`Reason: ${renderedReason || 'None'}`);
banPieces.push(`Note: ${renderedNote || 'None'}`);
banPieces.push(`Message: ${renderedContent === undefined ? 'None' : `${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`}`);
banPieces.push(`Reason: ${this.reason || 'None'}`);
banPieces.push(`Note: ${this.note || 'None'}`);
const durText = this.duration === undefined ? 'permanently' : `for ${this.duration} days`;
this.logger.info(`Banning ${item.author.name} ${durText}${this.reason !== undefined ? ` (${this.reason})` : ''}`);
this.logger.verbose(`\r\n${banPieces.join('\r\n')}`);
@@ -70,8 +56,8 @@ export class BanAction extends Action {
const bannedUser = await fetchedSub.banUser({
name: fetchedName,
banMessage: renderedContent === undefined ? undefined : renderedContent,
banReason: renderedReason,
banNote: renderedNote,
banReason: this.reason,
banNote: this.note,
duration: this.duration
});
touchedEntities.push(bannedUser);
@@ -79,14 +65,8 @@ export class BanAction extends Action {
return {
dryRun,
success: true,
result: `Banned ${item.author.name} ${durText}${renderedReason !== undefined ? ` (${renderedReason})` : ''}`,
touchedEntities,
data: {
message: renderedContent === undefined ? undefined : renderedContent,
reason: renderedReason,
note: renderedNote,
duration: durText
}
result: `Banned ${item.author.name} ${durText}${this.reason !== undefined ? ` (${this.reason})` : ''}`,
touchedEntities
};
}
@@ -117,10 +97,8 @@ export interface BanActionConfig extends ActionConfig, Footer {
* */
message?: string
/**
* Reason for ban. Can use Templating.
*
* If the length expands to more than 100 characters it will truncated with "..."
*
* Reason for ban.
* @maxLength 100
* @examples ["repeat spam"]
* */
reason?: string
@@ -132,10 +110,8 @@ export interface BanActionConfig extends ActionConfig, Footer {
* */
duration?: number
/**
* A mod note for this ban. Can use Templating.
*
* If the length expands to more than 100 characters it will truncated with "..."
*
* A mod note for this ban
* @maxLength 100
* @examples ["Sock puppet for u/AnotherUser"]
* */
note?: string

View File

@@ -12,7 +12,6 @@ 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)[];
@@ -36,7 +35,7 @@ export class CancelDispatchAction extends Action {
this.targets = !Array.isArray(target) ? [target] : target;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
// see note in DispatchAction about missing runtimeDryrun
const dryRun = this.dryRun;

View File

@@ -1,21 +1,12 @@
import Action, {ActionJson, ActionOptions} from "./index";
import {Comment, VoteableContent} from "snoowrap";
import {Comment} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {activityIsRemoved, renderContent} from "../Utils/SnoowrapUtils";
import {renderContent} from "../Utils/SnoowrapUtils";
import {ActionProcessResult, Footer, RequiredRichContent, RichContent, RuleResult} from "../Common/interfaces";
import {
asComment,
asSubmission,
getActivitySubredditName,
parseRedditThingsFromLink,
truncateStringToLength
} from "../util";
import {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 {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
export class CommentAction extends Action {
content: string;
@@ -23,8 +14,6 @@ export class CommentAction extends Action {
sticky: boolean = false;
distinguish: boolean = false;
footer?: false | string;
targets: ArbitraryActionTarget[]
asModTeam: boolean;
constructor(options: CommentActionOptions) {
super(options);
@@ -34,172 +23,71 @@ export class CommentAction extends Action {
sticky = false,
distinguish = false,
footer,
targets = ['self'],
asModTeam = false,
} = options;
this.footer = footer;
this.content = content;
this.lock = lock;
this.sticky = sticky;
this.asModTeam = asModTeam;
this.distinguish = distinguish;
if (!Array.isArray(targets)) {
this.targets = [targets];
} else {
this.targets = targets;
}
}
getKind(): ActionTypes {
return 'comment';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const body = await this.renderContent(this.content, item, ruleResults, actionResults) as string;
const content = await this.resources.getContent(this.content, item.subreddit);
const body = await renderContent(content, item, ruleResults, this.resources.userNotes);
const footer = await this.resources.renderFooter(item, this.footer);
const footer = await this.resources.generateFooter(item, this.footer);
const renderedContent = `${body}${footer}`;
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
let allErrors = true;
const targetResults: string[] = [];
if(item.archived) {
this.logger.warn('Cannot comment because Item is archived');
return {
dryRun,
success: false,
result: 'Cannot comment because Item is archived'
};
}
const touchedEntities = [];
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;
}
if(this.asModTeam) {
if(!targetItem.can_mod_post) {
const noMod = `[${targetIdentifier}] Cannot comment as subreddit because bot is not a moderator`;
this.logger.warn(noMod);
targetResults.push(noMod);
continue;
}
if(getActivitySubredditName(targetItem) !== this.resources.subreddit.display_name) {
const wrongSubreddit = `[${targetIdentifier}] Will not comment as subreddit because Activity did not occur in the same subreddit as the bot is moderating`;
this.logger.warn(wrongSubreddit);
targetResults.push(wrongSubreddit);
continue;
}
if(!activityIsRemoved(targetItem)) {
const notRemoved = `[${targetIdentifier}] Cannot comment as subreddit because Activity IS NOT REMOVED.`
this.logger.warn(notRemoved);
targetResults.push(notRemoved);
continue;
}
}
let modifiers = [];
let reply: Comment;
if (!dryRun) {
if(this.asModTeam) {
try {
reply = await this.client.addRemovalMessage(targetItem, renderedContent, 'public_as_subreddit',{lock: this.lock});
} catch (e: any) {
this.logger.warn(new CMError('Could not comment as subreddit', {cause: e}));
targetResults.push(`Could not comment as subreddit: ${e.message}`);
continue;
}
} else {
// @ts-ignore
reply = await targetItem.reply(renderedContent);
}
// add to recent so we ignore activity when/if it is discovered by polling
await this.resources.setRecentSelf(reply);
touchedEntities.push(reply);
}
if (!this.asModTeam && this.lock && targetItem.can_mod_post) {
if (!targetItem.can_mod_post) {
this.logger.warn(`[${targetIdentifier}] Cannot lock because bot is not a moderator`);
} else {
modifiers.push('Locked');
if (!dryRun) {
// snoopwrap typing issue, thinks comments can't be locked
// @ts-ignore
await reply.lock();
}
}
}
if (!this.asModTeam && this.distinguish) {
if (!targetItem.can_mod_post) {
this.logger.warn(`[${targetIdentifier}] Cannot lock Distinguish/Sticky because bot is not a moderator`);
} else {
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(' | ')} == =>`;
let modifiers = [];
let reply: Comment;
if(!dryRun) {
// @ts-ignore
targetResults.push(`${targetIdentifier}${modifierStr} created Comment ${dryRun ? 'DRYRUN' : (reply as SnoowrapActivity).name}`)
allErrors = false;
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');
if (!dryRun) {
// snoopwrap typing issue, thinks comments can't be locked
// @ts-ignore
await reply.lock();
}
}
if (this.distinguish && !dryRun) {
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(' | ')}]`;
return {
dryRun,
success: !allErrors,
result: `${targetResults.join('\n')}${truncateStringToLength(100)(body)}`,
success: true,
result: `${modifierStr}${truncateStringToLength(100)(body)}`,
touchedEntities,
data: {
body,
bodyShort: truncateStringToLength(100)(body),
comments: targetResults,
commentsFormatted: targetResults.map(x => `* ${x}`).join('\n')
}
};
}
@@ -209,8 +97,7 @@ export class CommentAction extends Action {
lock: this.lock,
sticky: this.sticky,
distinguish: this.distinguish,
footer: this.footer,
targets: this.targets,
footer: this.footer
}
}
}
@@ -228,31 +115,6 @@ 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[]
/**
* Comment "as subreddit" using the "/u/subreddit-ModTeam" account
*
* RESTRICTIONS:
*
* * Target activity must ALREADY BE REMOVED
* * Will always distinguish and sticky the created comment
* */
asModTeam?: boolean
}
export interface CommentActionOptions extends CommentActionConfig, ActionOptions {
@@ -262,5 +124,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'
}

View File

@@ -7,7 +7,6 @@ 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 {
@@ -26,7 +25,7 @@ export class ContributorAction extends Action {
this.actionType = action;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const contributors = await this.resources.getSubredditContributors();

View File

@@ -8,7 +8,6 @@ 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;
@@ -40,7 +39,7 @@ export class DispatchAction extends Action {
this.targets = !Array.isArray(target) ? [target] : target;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], 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?)

View File

@@ -5,14 +5,13 @@ 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[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const touchedEntities = [];
//snoowrap typing issue, thinks comments can't be locked

View File

@@ -16,7 +16,6 @@ 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;
@@ -49,30 +48,27 @@ export class MessageAction extends Action {
return 'message';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const content = await this.resources.getContent(this.content);
const body = await renderContent(content, item, ruleResults, this.resources.userNotes);
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.renderFooter(item, this.footer);
const footer = await this.resources.generateFooter(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(renderedTo, 'user');
const entityData = parseRedditEntity(this.to, '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, given value after templating: ${renderedTo} -- See ${REDDIT_ENTITY_REGEX_URL} for valid examples`, {cause: err});
throw new ErrorWithCause(`'to' field for message was not in a valid format. 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}`);
@@ -84,7 +80,7 @@ export class MessageAction extends Action {
text: renderedContent,
// @ts-ignore
fromSubreddit: this.asSubreddit ? await item.subreddit.fetch() : undefined,
subject: subject,
subject: this.title || `Concerning your ${isSubmission(item) ? 'Submission' : 'Comment'}`,
};
const msgPreview = `\r\n
@@ -126,7 +122,7 @@ export interface MessageActionConfig extends RequiredRichContent, Footer {
asSubreddit: boolean
/**
* Entity to send message to. It can be templated.
* Entity to send message to.
*
* If not present Message be will sent to the Author of the Activity being checked.
*
@@ -138,9 +134,8 @@ export interface MessageActionConfig extends RequiredRichContent, Footer {
*
* **Note:** Reddit does not support sending a message AS a subreddit TO another subreddit
*
* **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}}"]
* @pattern ^\s*(\/[ru]\/|[ru]\/)*(\w+)*\s*$
* @examples ["aUserName","u/aUserName","r/aSubreddit"]
* */
to?: string

View File

@@ -1,134 +0,0 @@
import {ActionJson, ActionConfig, ActionOptions} from "./index";
import Action from "./index";
import {Comment} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {ActionProcessResult, RichContent} from "../Common/interfaces";
import {buildFilterCriteriaSummary, normalizeModActionCriteria, toModNoteLabel} from "../util";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {
ActionTypes,
ModUserNoteLabel,
} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {ModNoteCriteria} from "../Common/Infrastructure/Filters/FilterCriteria";
export class ModNoteAction extends Action {
content: string;
type?: string;
existingNoteCheck?: ModNoteCriteria
referenceActivity: boolean
constructor(options: ModNoteActionOptions) {
super(options);
const {type, content = '', existingNoteCheck = true, referenceActivity = true} = options;
this.type = type;
this.content = content;
this.referenceActivity = referenceActivity;
this.existingNoteCheck = typeof existingNoteCheck === 'boolean' ? this.generateModLogCriteriaFromDuplicateConvenience(existingNoteCheck) : normalizeModActionCriteria(existingNoteCheck);
}
getKind(): ActionTypes {
return 'modnote';
}
protected getSpecificPremise(): object {
return {
content: this.content,
type: this.type,
existingNoteCheck: this.existingNoteCheck,
referenceActivity: this.referenceActivity,
}
}
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 renderedContent = await this.renderContent(this.content, item, ruleResults, actionResults);
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
let noteCheckPassed: boolean = true;
let noteCheckResult: undefined | string;
if(this.existingNoteCheck === undefined) {
// nothing to do!
noteCheckResult = 'existingNoteCheck=false so no existing note checks were performed.';
} else {
const noteCheckCriteriaResult = await this.resources.isAuthor(item, {
modActions: [this.existingNoteCheck]
});
noteCheckPassed = noteCheckCriteriaResult.passed;
const {details} = buildFilterCriteriaSummary(noteCheckCriteriaResult);
noteCheckResult = `${noteCheckPassed ? 'Existing note check condition succeeded' : 'Will not add note because existing note check condition failed'} -- ${details.join(' ')}`;
}
this.logger.info(noteCheckResult);
if (!noteCheckPassed) {
return {
dryRun,
success: false,
result: noteCheckResult
};
}
if (!dryRun) {
await this.resources.addModNote({
label: modLabel,
note: renderedContent,
activity: this.referenceActivity ? item : undefined,
subreddit: this.resources.subreddit,
user: item.author
});
}
return {
success: true,
dryRun,
result: `${modLabel !== undefined ? `(${modLabel})` : ''} ${renderedContent}`
}
}
generateModLogCriteriaFromDuplicateConvenience(val: boolean): ModNoteCriteria | undefined {
if(val) {
return {
noteType: this.type !== undefined ? [toModNoteLabel(this.type)] : undefined,
note: this.content !== '' ? [this.content] : undefined,
referencesCurrentActivity: this.referenceActivity ? true : undefined,
search: 'current',
count: '< 1'
}
}
return undefined;
}
}
export interface ModNoteActionConfig extends ActionConfig, RichContent {
/**
* Check if there is an existing Note matching some criteria before adding the Note.
*
* If this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.
*
* Boolean convenience:
*
* * If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria
* * If `false` then no check is performed and Note is always added
*
* @examples [true]
* @default true
* */
existingNoteCheck?: boolean | ModNoteCriteria,
type?: ModUserNoteLabel
referenceActivity?: boolean
}
export interface ModNoteActionOptions extends Omit<ModNoteActionConfig, 'authorIs' | 'itemIs'>, ActionOptions {
}
/**
* Add a Toolbox User Note to the Author of this Activity
* */
export interface ModNoteActionJson extends ModNoteActionConfig, ActionJson {
kind: 'modnote'
}

View File

@@ -4,17 +4,13 @@ import Snoowrap, {Comment, Submission} from "snoowrap";
import {activityIsRemoved} from "../Utils/SnoowrapUtils";
import {ActionProcessResult, RuleResult} from "../Common/interfaces";
import dayjs from "dayjs";
import {isSubmission, truncateStringToLength} from "../util";
import {isSubmission} 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 {
spam: boolean;
note?: string;
reasonId?: string;
getKind(): ActionTypes {
return 'remove';
@@ -24,54 +20,21 @@ export class RemoveAction extends Action {
super(options);
const {
spam = false,
note,
reasonId,
} = options;
this.spam = spam;
this.note = note;
this.reasonId = reasonId;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const touchedEntities = [];
let removeSummary = [];
// issue with snoowrap typings, doesn't think prop exists on Submission
// @ts-ignore
if (activityIsRemoved(item)) {
this.logger.warn('It looks like this Item is already removed!');
}
if (this.spam) {
removeSummary.push('Marked as SPAM');
this.logger.verbose('Marking as spam on removal');
}
const renderedNote = await this.renderContent(this.note, item, ruleResults, actionResults);
let foundReasonId: string | undefined;
let foundReason: string | undefined;
if(this.reasonId !== undefined) {
const reason = await this.resources.getSubredditRemovalReasonById(this.reasonId);
if(reason === undefined) {
const reasonWarn = [`Could not find any Removal Reason with the ID ${this.reasonId}!`];
if(renderedNote === undefined) {
reasonWarn.push('Cannot add any Removal Reason because note is also empty!');
} else {
reasonWarn.push('Will add Removal Reason but only with note.');
}
this.logger.warn(reasonWarn.join(''));
} else {
foundReason = truncate(reason.title);
foundReasonId = reason.id;
removeSummary.push(`Reason: ${truncate(foundReason)} (${foundReasonId})`);
}
}
if(renderedNote !== undefined) {
removeSummary.push(`Note: ${truncate(renderedNote)}`);
}
this.logger.verbose(removeSummary.join(' | '));
if (!dryRun) {
// @ts-ignore
await item.remove({spam: this.spam});
@@ -81,18 +44,6 @@ export class RemoveAction extends Action {
// @ts-ignore
item.removed = true;
}
if(foundReasonId !== undefined || renderedNote !== undefined) {
await this.client.addRemovalReason(item, renderedNote, foundReasonId);
item.mod_reason_by = this.resources.botAccount as string;
if(renderedNote !== undefined) {
item.removal_reason = renderedNote;
}
if(foundReason !== undefined) {
item.mod_reason_title = foundReason;
}
}
await this.resources.resetCacheForItem(item);
touchedEntities.push(item);
}
@@ -100,8 +51,7 @@ export class RemoveAction extends Action {
return {
dryRun,
success: true,
touchedEntities,
result: removeSummary.join(' | ')
touchedEntities
}
}
@@ -116,22 +66,7 @@ export interface RemoveOptions extends Omit<RemoveActionConfig, 'authorIs' | 'it
}
export interface RemoveActionConfig extends ActionConfig {
/** (Optional) Mark Activity as spam */
spam?: boolean
/** (Optional) A mod-readable note added to the removal reason for this Activity. Can use Templating.
*
* This note (and removal reasons) are only visible on New Reddit
* */
note?: string
/** (Optional) The ID of the Removal Reason to use
*
* Removal reasons are only visible on New Reddit
*
* To find IDs for removal reasons check the "Removal Reasons" popup located in the CM dashboard config editor for your subreddit
*
* More info on Removal Reasons: https://mods.reddithelp.com/hc/en-us/articles/360010094892-Removal-Reasons
* */
reasonId?: string
}
/**

View File

@@ -7,7 +7,6 @@ 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
@@ -26,9 +25,10 @@ export class ReportAction extends Action {
return 'report';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string);
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
this.logger.verbose(`Contents:\r\n${renderedContent}`);
const truncatedContent = reportTrunc(renderedContent);
const touchedEntities = [];

View File

@@ -1,329 +0,0 @@
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'
}

View File

@@ -6,7 +6,6 @@ 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;
@@ -27,7 +26,7 @@ export class FlairAction extends Action {
return 'flair';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
let flairParts = [];
if(this.text !== '') {

View File

@@ -4,7 +4,6 @@ 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;
@@ -23,7 +22,7 @@ export class UserFlairAction extends Action {
return 'userflair';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
let flairParts = [];

View File

@@ -1,76 +1,57 @@
import {ActionJson, ActionConfig, ActionOptions} from "./index";
import Action from "./index";
import {Comment} from "snoowrap";
import {renderContent} from "../Utils/SnoowrapUtils";
import {UserNoteJson} from "../Subreddit/UserNotes";
import Submission from "snoowrap/dist/objects/Submission";
import {ActionProcessResult, RuleResult} from "../Common/interfaces";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes, UserNoteType} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {
FullUserNoteCriteria,
toFullUserNoteCriteria, UserNoteCriteria
} from "../Common/Infrastructure/Filters/FilterCriteria";
import {buildFilterCriteriaSummary} from "../util";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
export class UserNoteAction extends Action {
content: string;
type: UserNoteType;
existingNoteCheck?: UserNoteCriteria
type: string;
allowDuplicate: boolean;
constructor(options: UserNoteActionOptions) {
super(options);
const {type, content = '', existingNoteCheck = true, allowDuplicate} = options;
const {type, content = '', allowDuplicate = false} = options;
this.type = type;
this.content = content;
if(typeof existingNoteCheck !== 'boolean') {
this.existingNoteCheck = existingNoteCheck;
} else {
let exNotecheck: boolean;
if(allowDuplicate !== undefined) {
exNotecheck = !allowDuplicate;
} else {
exNotecheck = existingNoteCheck;
}
this.existingNoteCheck = this.generateCriteriaFromDuplicateConvenience(exNotecheck);
}
this.allowDuplicate = allowDuplicate;
}
getKind(): ActionTypes {
return 'usernote';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string);
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
let noteCheckPassed: boolean = true;
let noteCheckResult: undefined | string;
if(this.existingNoteCheck === undefined) {
// nothing to do!
noteCheckResult = 'existingNoteCheck=false so no existing note checks were performed.';
} else {
const noteCheckCriteriaResult = await this.resources.isAuthor(item, {
userNotes: [this.existingNoteCheck]
});
noteCheckPassed = noteCheckCriteriaResult.passed;
const {details} = buildFilterCriteriaSummary(noteCheckCriteriaResult);
noteCheckResult = `${noteCheckPassed ? 'Existing note check condition succeeded' : 'Will not add note because existing note check condition failed'} -- ${details.join(' ')}`;
if (!this.allowDuplicate) {
const notes = await this.resources.userNotes.getUserNotes(item.author);
let existingNote = notes.find((x) => x.link !== null && x.link.includes(item.id));
if(existingNote === undefined && notes.length > 0) {
const lastNote = notes[notes.length - 1];
// possibly notes don't have a reference link so check if last one has same text
if(lastNote.link === null && lastNote.text === renderedContent) {
existingNote = lastNote;
}
}
if (existingNote !== undefined && existingNote.noteType === this.type) {
this.logger.info(`Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`);
return {
dryRun,
success: false,
result: `Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`
};
}
}
this.logger.info(noteCheckResult);
if (!noteCheckPassed) {
return {
dryRun,
success: false,
result: noteCheckResult
};
}
if (!dryRun) {
await this.resources.userNotes.addUserNote(item, this.type, renderedContent, this.name !== undefined ? `(Action ${this.name})` : '');
} else if (!await this.resources.userNotes.warningExists(this.type)) {
@@ -83,23 +64,11 @@ export class UserNoteAction extends Action {
}
}
generateCriteriaFromDuplicateConvenience(val: boolean): UserNoteCriteria | undefined {
if(val) {
return {
type: this.type,
note: this.content !== '' && this.content !== undefined && this.content !== null ? [this.content] : undefined,
search: 'current',
count: '< 1'
};
}
return undefined;
}
protected getSpecificPremise(): object {
return {
content: this.content,
type: this.type,
existingNoteCheck: this.existingNoteCheck
allowDuplicate: this.allowDuplicate
}
}
}
@@ -107,29 +76,10 @@ export class UserNoteAction extends Action {
export interface UserNoteActionConfig extends ActionConfig,UserNoteJson {
/**
* Add Note even if a Note already exists for this Activity
*
* USE `existingNoteCheck` INSTEAD
*
* @examples [false]
* @default false
* @deprecated
* */
allowDuplicate?: boolean,
/**
* Check if there is an existing Note matching some criteria before adding the Note.
*
* If this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.
*
* Boolean convenience:
*
* * If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria
* * If `false` then no check is performed and Note is always added
*
* @examples [true]
* @default true
* */
existingNoteCheck?: boolean | UserNoteCriteria,
}
export interface UserNoteActionOptions extends Omit<UserNoteActionConfig, 'authorIs' | 'itemIs'>, ActionOptions {

View File

@@ -19,8 +19,6 @@ 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;
@@ -31,8 +29,6 @@ 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);
@@ -44,7 +40,6 @@ export abstract class Action extends RunnableBase {
subredditName,
dryRun = false,
emitter,
checkName,
} = options;
this.name = name;
@@ -53,8 +48,6 @@ 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;
@@ -119,7 +112,7 @@ export abstract class Action extends RunnableBase {
}
}
async handle(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionResultEntity> {
async handle(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionResultEntity> {
const {dryRun: runtimeDryrun} = options;
const dryRun = runtimeDryrun || this.dryRun;
@@ -155,11 +148,10 @@ export abstract class Action extends RunnableBase {
actRes.runReason = runReason;
return actRes;
}
const results = await this.process(item, ruleResults, actionResults, options);
const results = await this.process(item, ruleResults, options);
actRes.success = results.success;
actRes.dryRun = results.dryRun;
actRes.result = results.result;
actRes.data = results.data;
actRes.touchedEntities = results.touchedEntities ?? [];
return actRes;
@@ -174,31 +166,20 @@ export abstract class Action extends RunnableBase {
}
}
abstract process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult>;
abstract process(item: Comment | Submission, ruleResults: RuleResultEntity[], 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 ActionRuntimeOptions {
checkName: string
subredditName: string
export interface ActionOptions extends Omit<ActionConfig, 'authorIs' | 'itemIs'>, RunnableBaseOptions {
//logger: Logger;
subredditName: string;
//resources: SubredditResources;
client: ExtendedSnoowrap;
emitter: EventEmitter;
resources: SubredditResources;
logger: Logger;
}
export interface ActionOptions extends Omit<ActionConfig, 'authorIs' | 'itemIs'>, RunnableBaseOptions, ActionRuntimeOptions {
emitter: EventEmitter
}
export interface ActionConfig extends RunnableBaseJson {

View File

@@ -4,27 +4,24 @@ import {getLogger} from "./Utils/loggerFactory";
import {DatabaseMigrationOptions, OperatorConfig, OperatorConfigWithFileContext, OperatorFileConfig} from "./Common/interfaces";
import Bot from "./Bot";
import LoggedError from "./Utils/LoggedError";
import {generateRandomName, mergeArr, sleep} from "./util";
import {copyFile, open} from "fs/promises";
import {mergeArr, sleep} from "./util";
import {copyFile} from "fs/promises";
import {constants} from "fs";
import {Connection, DataSource, Repository} from "typeorm";
import {Connection} from "typeorm";
import {ErrorWithCause} from "pony-cause";
import {MigrationService} from "./Common/MigrationService";
import {Invokee} from "./Common/Infrastructure/Atomic";
import {DatabaseConfig} from "./Common/Infrastructure/Database";
import {InviteData} from "./Web/Common/interfaces";
import {BotInvite} from "./Common/Entities/BotInvite";
export class App {
bots: Bot[] = [];
logger: Logger;
dbLogger: Logger;
database: DataSource
database: Connection
startedAt: Dayjs = dayjs();
ranMigrations: boolean = false;
migrationBlocker?: string;
friendly?: string;
config: OperatorConfig;
@@ -33,7 +30,6 @@ export class App {
fileConfig: OperatorFileConfig;
migrationService: MigrationService;
inviteRepo: Repository<BotInvite>;
constructor(config: OperatorConfigWithFileContext) {
const {
@@ -53,8 +49,6 @@ export class App {
this.logger = getLogger(config.logging);
this.dbLogger = this.logger.child({labels: ['Database']}, mergeArr);
this.database = database;
this.inviteRepo = this.database.getRepository(BotInvite);
this.friendly = this.config.api.friendly;
this.logger.info(`Operators: ${name.length === 0 ? 'None Specified' : name.join(', ')}`)
@@ -120,8 +114,6 @@ export class App {
return;
}
await this.checkFriendlyName();
if(this.bots.length > 0) {
this.logger.info('Bots already exist, will stop and destroy these before building new ones.');
await this.destroy(causedBy);
@@ -141,7 +133,7 @@ export class App {
for (const b of this.bots) {
if (b.error === undefined) {
try {
await b.init();
await b.testClient();
await b.buildManagers();
await sleep(2000);
b.runManagers(causedBy).catch((err) => {
@@ -169,54 +161,4 @@ export class App {
await b.destroy(causedBy);
}
}
async checkFriendlyName() {
if(this.friendly === undefined) {
let randFriendly: string = generateRandomName();
this.logger.verbose(`No friendly name set for Server. Generated: ${randFriendly}`);
const exists = async (name: string) => {
const existing = await this.inviteRepo.findBy({instance: name});
return existing.length > 0;
}
while (await exists(randFriendly)) {
let oldFriendly = randFriendly;
randFriendly = generateRandomName();
this.logger.verbose(`${oldFriendly} already exists! Generated: ${randFriendly}`);
}
this.friendly = randFriendly;
this.fileConfig.document.setFriendlyName(this.friendly);
const handle = await open(this.fileConfig.document.location as string, 'w');
await handle.writeFile(this.fileConfig.document.toString());
await handle.close();
this.logger.verbose(`Wrote ${randFriendly} as friendly server name to config.`);
}
}
async getInviteById(id: string): Promise<BotInvite | undefined> {
const invite = await this.inviteRepo.findOne({where: {id, instance: this.friendly}});
if(invite === null) {
return undefined;
}
return invite;
}
async getInviteIds(): Promise<string[]> {
if(!this.ranMigrations) {
// not ready!
return [];
}
const invites = await this.inviteRepo.findBy({instance: this.friendly});
return invites.map(x => x.id);
}
async addInvite(data: InviteData): Promise<InviteData> {
return await this.inviteRepo.save(new BotInvite(data));
}
async deleteInvite(id: string): Promise<void> {
await this.inviteRepo.delete({ id });
}
}

View File

@@ -1,224 +0,0 @@
import {SPoll} from "../Subreddit/Streams";
import Snoowrap from "snoowrap";
import {Cache} from "cache-manager";
import {
BotInstanceConfig,
StrongCache,
StrongTTLConfig,
ThirdPartyCredentialsJsonConfig,
TTLConfig
} from "../Common/interfaces";
import winston, {Logger} from "winston";
import {DataSource, Repository} from "typeorm";
import {
EventRetentionPolicyRange
} from "../Common/Infrastructure/Atomic";
import {InvokeeType} from "../Common/Entities/InvokeeType";
import {RunStateType} from "../Common/Entities/RunStateType";
import {buildCachePrefix, cacheStats, mergeArr, toStrongTTLConfig} from "../util";
import objectHash from "object-hash";
import {runMigrations} from "../Common/Migrations/CacheMigrationUtils";
import {CMError} from "../Utils/Errors";
import {DEFAULT_FOOTER, SubredditResources} from "../Subreddit/SubredditResources";
import {SubredditResourceConfig, SubredditResourceOptions} from "../Common/Subreddit/SubredditResourceInterfaces";
import {buildCacheOptionsFromProvider, CMCache, createCacheManager} from "../Common/Cache";
export class BotResourcesManager {
resources: Map<string, SubredditResources> = new Map();
authorTTL: number = 10000;
enabled: boolean = true;
modStreams: Map<string, SPoll<Snoowrap.Submission | Snoowrap.Comment>> = new Map();
defaultCache: CMCache;
defaultCacheConfig: StrongCache
defaultCacheMigrated: boolean = false;
cacheType: string = 'none';
cacheHash: string;
ttlDefaults: StrongTTLConfig
defaultThirdPartyCredentials: ThirdPartyCredentialsJsonConfig;
logger: Logger;
botAccount?: string;
defaultDatabase: DataSource
botName!: string
retention?: EventRetentionPolicyRange
invokeeRepo: Repository<InvokeeType>
runTypeRepo: Repository<RunStateType>
constructor(config: BotInstanceConfig, logger: Logger) {
const {
caching: {
authorTTL,
userNotesTTL,
wikiTTL,
commentTTL,
submissionTTL,
subredditTTL,
filterCriteriaTTL,
modNotesTTL,
selfTTL,
provider,
},
name,
credentials: {
reddit,
...thirdParty
},
database,
databaseConfig: {
retention
} = {},
caching,
} = config;
caching.provider.prefix = buildCachePrefix([caching.provider.prefix, 'SHARED']);
const {...relevantCacheSettings} = caching;
this.cacheHash = objectHash.sha1(relevantCacheSettings);
this.defaultCacheConfig = caching;
this.defaultThirdPartyCredentials = thirdParty;
this.defaultDatabase = database;
this.ttlDefaults = toStrongTTLConfig({
authorTTL,
userNotesTTL,
wikiTTL,
commentTTL,
submissionTTL,
filterCriteriaTTL,
subredditTTL,
selfTTL,
modNotesTTL
});
this.botName = name as string;
this.logger = logger;
this.invokeeRepo = this.defaultDatabase.getRepository(InvokeeType);
this.runTypeRepo = this.defaultDatabase.getRepository(RunStateType);
this.retention = retention;
const options = provider;
this.cacheType = options.store;
const cache = createCacheManager(options);
this.defaultCache = new CMCache(cache, options, true, caching.provider.prefix, this.ttlDefaults, this.logger);
}
get(subName: string): SubredditResources | undefined {
if (this.resources.has(subName)) {
return this.resources.get(subName) as SubredditResources;
}
return undefined;
}
async set(subName: string, initOptions: SubredditResourceConfig): Promise<SubredditResources> {
let hash = 'default';
const {caching, credentials, retention, ...init} = initOptions;
const res = this.get(subName);
let opts: SubredditResourceOptions = {
cache: this.defaultCache,
cacheType: this.cacheType,
cacheSettingsHash: hash,
ttl: this.ttlDefaults,
thirdPartyCredentials: credentials ?? this.defaultThirdPartyCredentials,
prefix: this.defaultCacheConfig.provider.prefix,
database: this.defaultDatabase,
botName: this.botName,
retention: retention ?? this.retention,
...init,
};
if (caching !== undefined) {
const {
provider = this.defaultCacheConfig.provider,
...rest
} = caching;
opts.ttl = toStrongTTLConfig({
...this.ttlDefaults,
...rest
});
const candidateProvider = buildCacheOptionsFromProvider(provider);
const defaultPrefix = candidateProvider.prefix;
const subPrefix = defaultPrefix === this.defaultCacheConfig.provider.prefix ? buildCachePrefix([(defaultPrefix !== undefined ? defaultPrefix.replace('SHARED', '') : defaultPrefix), subName]) : candidateProvider.prefix;
candidateProvider.prefix = subPrefix;
if(this.defaultCache.equalProvider(candidateProvider)) {
opts.cache = this.defaultCache;
} else if(res !== undefined && res.cache.equalProvider(candidateProvider)) {
opts.cache = res.cache;
} else {
opts.cache = new CMCache(createCacheManager(candidateProvider), candidateProvider, false, this.defaultCache.providerOptions.prefix, opts.ttl, this.logger);
await runMigrations(opts.cache.cache, opts.cache.logger, candidateProvider.prefix);
}
} else if (!this.defaultCacheMigrated) {
await runMigrations(this.defaultCache.cache, this.logger, opts.prefix);
this.defaultCacheMigrated = true;
}
let resource: SubredditResources;
if (res === undefined) {
resource = new SubredditResources(subName, {
...opts,
botAccount: this.botAccount
});
this.resources.set(subName, resource);
} else {
// just set non-cache related settings
resource = res;
resource.botAccount = this.botAccount;
}
await resource.configure(opts);
return resource;
}
async destroy(subName: string) {
const res = this.get(subName);
if (res !== undefined) {
await res.destroy();
this.resources.delete(subName);
}
}
async getPendingSubredditInvites(): Promise<(string[])> {
const subredditNames = await this.defaultCache.get(`modInvites`);
if (subredditNames !== undefined && subredditNames !== null) {
return subredditNames as string[];
}
return [];
}
async addPendingSubredditInvite(subreddit: string): Promise<void> {
if (subreddit === null || subreddit === undefined || subreddit == '') {
throw new CMError('Subreddit name cannot be empty');
}
let subredditNames = await this.defaultCache.get(`modInvites`) as (string[] | undefined | null);
if (subredditNames === undefined || subredditNames === null) {
subredditNames = [];
}
const cleanName = subreddit.trim();
if (subredditNames.some(x => x.trim().toLowerCase() === cleanName.toLowerCase())) {
throw new CMError(`An invite for the Subreddit '${subreddit}' already exists`);
}
subredditNames.push(cleanName);
await this.defaultCache.set(`modInvites`, subredditNames, {ttl: 0});
return;
}
async deletePendingSubredditInvite(subreddit: string): Promise<void> {
let subredditNames = await this.defaultCache.get(`modInvites`) as (string[] | undefined | null);
if (subredditNames === undefined || subredditNames === null) {
subredditNames = [];
}
subredditNames = subredditNames.filter(x => x.toLowerCase() !== subreddit.trim().toLowerCase());
await this.defaultCache.set(`modInvites`, subredditNames, {ttl: 0});
return;
}
async clearPendingSubredditInvites(): Promise<void> {
await this.defaultCache.del(`modInvites`);
return;
}
}

View File

@@ -1,5 +1,4 @@
import Snoowrap, {Comment, ConfigOptions, RedditUser, Submission} from "snoowrap";
import {Subreddit} from "snoowrap/dist/objects"
import Snoowrap, {Comment, ConfigOptions, RedditUser, Submission, Subreddit} from "snoowrap";
import {Logger} from "winston";
import dayjs, {Dayjs} from "dayjs";
import {Duration} from "dayjs/plugin/duration";
@@ -14,28 +13,21 @@ import {
USER
} from "../Common/interfaces";
import {
createRetryHandler, symmetricalDifference,
createRetryHandler, difference,
formatNumber, getExceptionMessage, getUserAgent,
mergeArr,
parseBool,
parseDuration, parseMatchMessage, parseRedditEntity,
parseSubredditName, partition, RetryOptions,
sleep, intersect
parseSubredditName, RetryOptions,
sleep
} from "../util";
import {Manager} from "../Subreddit/Manager";
import {ExtendedSnoowrap, ProxiedSnoowrap} from "../Utils/SnoowrapClients";
import {CommentStream, ModQueueStream, SPoll, SubmissionStream, UnmoderatedStream} from "../Subreddit/Streams";
import {BotResourcesManager} from "../Subreddit/SubredditResources";
import LoggedError from "../Utils/LoggedError";
import pEvent from "p-event";
import {
SimpleError,
isRateLimitError,
isRequestError,
isScopeError,
isStatusError,
CMError,
ISeriousError, definesSeriousError
} from "../Utils/Errors";
import {SimpleError, isRateLimitError, isRequestError, isScopeError, isStatusError, CMError} from "../Utils/Errors";
import {ErrorWithCause} from "pony-cause";
import {DataSource, Repository} from "typeorm";
import {Bot as BotEntity} from '../Common/Entities/Bot';
@@ -49,35 +41,19 @@ import {ManagerRunState} from "../Common/Entities/EntityRunState/ManagerRunState
import {Invokee, PollOn} from "../Common/Infrastructure/Atomic";
import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterShapes";
import {snooLogWrapper} from "../Utils/loggerFactory";
import {InfluxClient} from "../Common/Influx/InfluxClient";
import {Point} from "@influxdata/influxdb-client";
import {
BotInstanceFunctions, HydratedSubredditInviteData,
NormalizedManagerResponse,
SubredditInviteData,
SubredditInviteDataPersisted, SubredditOnboardingReadiness
} from "../Web/Common/interfaces";
import {AuthorEntity} from "../Common/Entities/AuthorEntity";
import {Guest, GuestEntityData} from "../Common/Entities/Guest/GuestInterfaces";
import {guestEntitiesToAll, guestEntityToApiGuest} from "../Common/Entities/Guest/GuestEntity";
import {SubredditInvite} from "../Common/Entities/SubredditInvite";
import {dayjsDTFormat} from "../Common/defaults";
import {BotResourcesManager} from "./ResourcesManager";
class Bot implements BotInstanceFunctions {
class Bot {
client!: ExtendedSnoowrap;
logger!: Logger;
logs: LogInfo[] = [];
wikiLocation: string;
dryRun?: true | undefined;
inited: boolean = false;
running: boolean = false;
subreddits: string[];
excludeSubreddits: string[];
filterCriteriaDefaults?: FilterCriteriaDefaults
subManagers: Manager[] = [];
moderatedSubreddits: Subreddit[] = []
heartbeatInterval: number;
nextHeartbeat: Dayjs = dayjs();
heartBeating: boolean = false;
@@ -95,7 +71,6 @@ class Bot implements BotInstanceFunctions {
botName?: string;
botLink?: string;
botAccount?: string;
botUser?: RedditUser;
maxWorkers: number;
startedAt: Dayjs = dayjs();
sharedStreams: PollOn[] = [];
@@ -115,15 +90,9 @@ class Bot implements BotInstanceFunctions {
config: BotInstanceConfig;
influxClients: InfluxClient[] = [];
database: DataSource
invokeeRepo: Repository<InvokeeType>;
runTypeRepo: Repository<RunStateType>;
managerRepo: Repository<ManagerEntity>;
authorRepo: Repository<AuthorEntity>;
subredditInviteRepo: Repository<SubredditInvite>
botRepo: Repository<BotEntity>
botEntity!: BotEntity
getBotName = () => {
@@ -185,10 +154,6 @@ class Bot implements BotInstanceFunctions {
this.database = database;
this.invokeeRepo = this.database.getRepository(InvokeeType);
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;
@@ -212,11 +177,8 @@ class Bot implements BotInstanceFunctions {
this.logger.stream().on('log', (log: LogInfo) => {
if(log.bot !== undefined && log.bot === this.getBotName() && log.subreddit === undefined) {
this.logs.unshift(log);
if(this.logs.length > 300) {
// remove all elements starting from the 300th index (301st item)
this.logs.splice(300);
}
const combinedLogs = [log, ...this.logs];
this.logs = combinedLogs.slice(0, 301);
}
});
@@ -349,22 +311,33 @@ class Bot implements BotInstanceFunctions {
}
}
async init() {
if(this.inited) {
return;
}
let user: RedditUser;
async testClient(initial = true) {
try {
user = await this.testClient();
} catch(err: any) {
this.logger.error('An error occurred while trying to initialize the Reddit API Client which would prevent the Bot from running.');
throw err;
// @ts-ignore
await this.client.getMe();
this.logger.info('Test API call successful');
} catch (err: any) {
if (initial) {
this.logger.error('An error occurred while trying to initialize the Reddit API Client which would prevent the entire application from running.');
}
const hint = getExceptionMessage(err, {
401: 'Likely a credential is missing or incorrect. Check clientId, clientSecret, refreshToken, and accessToken',
400: 'Credentials may have been invalidated manually or by reddit due to behavior',
});
let msg = `Error occurred while testing Reddit API client${hint !== undefined ? `: ${hint}` : ''}`;
this.error = msg;
const clientError = new CMError(msg, {cause: err});
clientError.logged = true;
this.logger.error(clientError);
throw clientError;
}
}
async buildManagers(subreddits: string[] = []) {
let availSubs = [];
// @ts-ignore
const user = await this.client.getMe().fetch();
this.cacheManager.botName = user.name;
this.botUser = user;
this.botLink = `https://reddit.com/user/${user.name}`;
this.botAccount = `u/${user.name}`;
this.logger.info(`Reddit API Limit Remaining: ${this.client.ratelimitRemaining}`);
@@ -391,87 +364,35 @@ class Bot implements BotInstanceFunctions {
this.botEntity = b;
}
if(this.config.opInflux !== undefined) {
this.influxClients.push(this.config.opInflux.childClient(this.logger, {bot: user.name}));
if(this.config.influxConfig !== undefined) {
const iClient = new InfluxClient(this.config.influxConfig, this.logger, {bot: user.name});
await iClient.isReady();
this.influxClients.push(iClient);
}
}
this.inited = true;
}
// @ts-ignore
async testClient(initial = true) {
try {
// @ts-ignore
const user = await this.client.getMe().fetch();
this.logger.info('Test API call successful');
return user;
} catch (err: any) {
if (initial) {
this.logger.error('An error occurred while trying to initialize the Reddit API Client which would prevent the entire application from running.');
}
const hint = getExceptionMessage(err, {
401: 'Likely a credential is missing or incorrect. Check clientId, clientSecret, refreshToken, and accessToken',
400: 'Credentials may have been invalidated manually or by reddit due to behavior',
});
let msg = `Error occurred while testing Reddit API client${hint !== undefined ? `: ${hint}` : ''}`;
this.error = msg;
const clientError = new CMError(msg, {cause: err});
clientError.logged = true;
this.logger.error(clientError);
throw clientError;
}
}
async getModeratedSubreddits(refresh = false) {
if(this.moderatedSubreddits.length > 0 && !refresh) {
return this.moderatedSubreddits;
}
let subListing = await this.client.getModeratedSubreddits({count: 100});
while (!subListing.isFinished) {
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;
}
availSubs = subListing.filter(x => x.display_name !== `u_${user.name}`);
async buildManagers(subreddits: string[] = []) {
await this.init();
this.logger.verbose('Syncing subreddits to moderate with managers...');
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(', ')}`);
this.logger.info(`u/${user.name} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
let subsToRun: Subreddit[] = [];
const subsToUse = subreddits.length > 0 ? subreddits.map(parseSubredditName) : this.subreddits;
if (subsToUse.length > 0) {
this.logger.info(`Operator-specified subreddit constraints detected, will only use these: ${subsToUse.join(', ')}`);
const availSubsCI = availSubs.map(x => x.display_name.toLowerCase());
const [foundSubs, notFoundSubs] = partition(subsToUse, (aSub) => availSubsCI.includes(aSub.toLowerCase()));
if(notFoundSubs.length > 0) {
this.logger.warn(`Will not run some operator-specified subreddits because they are not modded by, or do not have appropriate mod permissions for, this bot: ${notFoundSubs.join(', ')}`);
}
for (const sub of foundSubs) {
this.logger.info(`Operator-defined subreddit constraints detected (CLI argument or environmental variable), will try to run on: ${subsToUse.join(', ')}`);
for (const sub of subsToUse) {
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.toLowerCase())
subsToRun.push(asub as Subreddit);
if (asub === undefined) {
this.logger.warn(`Will not run on ${sub} because is not modded by, or does not have appropriate permissions to mod with, for this client.`);
} else {
// @ts-ignore
const fetchedSub = await asub.fetch();
subsToRun.push(fetchedSub);
}
}
} else {
if(this.excludeSubreddits.length > 0) {
this.logger.info(`Will run on all moderated subreddits EXCEPT own profile and operator-defined excluded: ${this.excludeSubreddits.join(', ')}`);
this.logger.info(`Will run on all moderated subreddits but own profile and user-defined excluded: ${this.excludeSubreddits.join(', ')}`);
const normalExcludes = this.excludeSubreddits.map(x => x.toLowerCase());
subsToRun = availSubs.filter(x => !normalExcludes.includes(x.display_name.toLowerCase()));
} else {
this.logger.info(`No operator-defined subreddit constraints detected, will run on all moderated subreddits EXCEPT own profile (${this.botAccount})`);
this.logger.info(`No user-defined subreddit constraints detected, will run on all moderated subreddits EXCEPT own profile (${this.botAccount})`);
subsToRun = availSubs;
}
}
@@ -494,66 +415,30 @@ class Bot implements BotInstanceFunctions {
return acc;
}
}, []);
const notMatched = symmetricalDifference(normalizedOverrideNames, subsToRunNames);
const notMatched = difference(normalizedOverrideNames, subsToRunNames);
if(notMatched.length > 0) {
this.logger.warn(`There are overrides defined for subreddits the bot is not running. Check your spelling! Overrides not matched: ${notMatched.join(', ')}`);
}
}
let subManagersChanged = false;
const subsToRunNames = subsToRun.map(x => x.display_name.toLowerCase());
// first stop and remove any managers with subreddits not in subsToRun
// -- this covers scenario where bot is running and mods of a subreddit de-mod the bot
// -- or where the include/exclude subs list changed from operator (not yet implemented)
if(this.subManagers.length > 0) {
let index = 0;
for(const manager of this.subManagers) {
if(!subsToRunNames.includes(manager.subreddit.display_name.toLowerCase())) {
subManagersChanged = true;
// determine if bot was de-modded
const deModded = !availSubs.some(x => x.display_name.toLowerCase() === manager.subreddit.display_name.toLowerCase());
this.logger.warn(`Stopping and removing manager for ${manager.subreddit.display_name.toLowerCase()} because it is ${deModded ? 'no longer moderated by this bot' : 'not in the list of subreddits to moderate'}`);
await manager.destroy('system', {reason: deModded ? 'No longer moderated by this bot' : 'Subreddit is not in moderated list'});
this.subManagers.splice(index, 1);
}
index++;
}
}
// then create any managers that don't already exist
// -- covers init scenario
// -- and in-situ adding subreddits IE bot is modded to a new subreddit while CM is running
const subsToInit: string[] = [];
// get configs for subs we want to run on and build/validate them
for (const sub of subsToRun) {
if(!this.subManagers.some(x => x.subreddit.display_name === sub.display_name)) {
subManagersChanged = true;
this.logger.info(`Manager for ${sub.display_name_prefixed} not found in loaded managers. Loading now...`);
subsToInit.push(sub.display_name);
try {
this.subManagers.push(await this.createManager(sub));
} catch (err: any) {
try {
this.subManagers.push(await this.createManager(sub));
} catch (err: any) {
}
}
}
for(const subName of subsToInit) {
for(const m of this.subManagers) {
try {
const m = this.subManagers.find(x => x.subreddit.display_name === subName);
await this.initManager(m as Manager);
await this.initManager(m);
} catch (err: any) {
}
}
if(!subManagersChanged) {
this.logger.verbose('All managers were already synced!');
} else {
this.parseSharedStreams();
}
return subManagersChanged;
this.parseSharedStreams();
}
parseSharedStreams() {
@@ -665,7 +550,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`, {cause: err});
const normalizedError = new ErrorWithCause(`Bot could not initialize manager because config was not valid`, {cause: err});
// @ts-ignore
this.logger.error(normalizedError, {subreddit: manager.subreddit.display_name_prefixed});
} else {
@@ -674,7 +559,7 @@ class Bot implements BotInstanceFunctions {
}
}
async createManager(subVal: Subreddit): Promise<Manager> {
async createManager(sub: Subreddit): Promise<Manager> {
const {
flowControlDefaults: {
maxGotoDepth: botMaxDefault
@@ -685,15 +570,6 @@ class Bot implements BotInstanceFunctions {
} = {}
} = this.config;
let sub = subVal;
// make sure the subreddit is fully fetched
// @ts-ignore
if(subVal._hasFetched === false) {
// @ts-ignore
sub = await subVal.fetch();
}
const override = overrides.find(x => {
const configName = parseRedditEntity(x.name).name;
if(configName !== undefined) {
@@ -710,15 +586,15 @@ class Bot implements BotInstanceFunctions {
databaseConfig: {
retention = undefined
} = {},
wikiConfig = this.wikiLocation,
} = override || {};
const managerRepo = this.database.getRepository(ManagerEntity);
const subRepo = this.database.getRepository(SubredditEntity)
let subreddit = await subRepo.findOne({where: {id: sub.name}});
if(subreddit === null) {
subreddit = await subRepo.save(new SubredditEntity({id: sub.name, name: sub.display_name}))
}
let managerEntity = await this.managerRepo.findOne({
let managerEntity = await managerRepo.findOne({
where: {
bot: {
id: this.botEntity.id
@@ -727,15 +603,12 @@ class Bot implements BotInstanceFunctions {
id: subreddit.id
}
},
relations: {
guests: true
}
});
if(managerEntity === undefined || managerEntity === null) {
const invokee = await this.invokeeRepo.findOneBy({name: SYSTEM}) as InvokeeType;
const runType = await this.runTypeRepo.findOneBy({name: STOPPED}) as RunStateType;
managerEntity = await this.managerRepo.save(new ManagerEntity({
managerEntity = await managerRepo.save(new ManagerEntity({
name: sub.display_name,
bot: this.botEntity,
subreddit: subreddit as SubredditEntity,
@@ -743,15 +616,12 @@ class Bot implements BotInstanceFunctions {
eventsState: new EventsRunState({invokee, runType}),
managerState: new ManagerRunState({invokee, runType})
}));
this.logger.info(`Created new Manager (${managerEntity.id}) for ${subVal.display_name}`);
} else {
this.logger.info(`Found existing Manager (${managerEntity.id}) for ${subVal.display_name}`);
}
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {
dryRun: this.dryRun,
sharedStreams: this.sharedStreams,
wikiLocation: wikiConfig,
wikiLocation: this.wikiLocation,
botName: this.botName as string,
maxWorkers: this.maxWorkers,
filterCriteriaDefaults: this.filterCriteriaDefaults,
@@ -760,7 +630,6 @@ class Bot implements BotInstanceFunctions {
managerEntity: managerEntity as ManagerEntity,
statDefaults: (statDefaultsFromOverride ?? databaseStatisticsDefaults) as DatabaseStatisticsOperatorConfig,
retention,
influxClients: this.influxClients,
});
// all errors from managers will count towards bot-level retry count
manager.on('error', async (err) => await this.panicOnRetries(err));
@@ -793,50 +662,36 @@ class Bot implements BotInstanceFunctions {
}
async checkModInvites() {
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()) {
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);
// @ts-ignore
const sub = await this.client.getSubreddit(name);
this.logger.info(`Attempting to add manager for r/${name}`);
try {
await this.acceptModInvite(subInvite);
await this.deleteSubredditInvite(subInvite);
const manager = await this.createManager(sub);
this.logger.info(`Starting manager for r/${name}`);
this.subManagers.push(manager);
await this.initManager(manager);
await manager.start('system', {reason: 'Caused by creation due to moderator invite'});
await this.runSharedStreams();
} catch (err: any) {
if(definesSeriousError(err) && !err.isSerious) {
this.logger.warn(err);
} else {
if (!(err instanceof LoggedError)) {
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.`;
} 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 {
msg = `${msg} If this subreddit is private it is likely no moderation invite exists.`;
this.logger.error(`Error occurred while checking r/${name} for a pending moderation invite. Error: ${err.message}`);
}
throw new CMError(msg, {cause: err})
} else {
throw new CMError(`Error occurred while checking r/${name} for a pending moderation invite.`, {cause: err});
}
}
}
@@ -887,15 +742,9 @@ class Bot implements BotInstanceFunctions {
async healthLoop() {
while (this.running) {
await sleep(5000);
const time = dayjs().valueOf()
await this.apiHealthCheck(time);
await this.guestModCleanup();
if (!this.running) {
break;
}
for(const m of this.subManagers) {
await m.writeHealthMetrics(time);
}
const now = dayjs();
if (now.isSameOrAfter(this.nextNannyCheck)) {
try {
@@ -908,17 +757,8 @@ class Bot implements BotInstanceFunctions {
}
if(now.isSameOrAfter(this.nextHeartbeat)) {
try {
// run sanity check to see if there is a service issue
try {
await this.testClient(false);
} catch (err: any) {
throw new SimpleError(`Something isn't right! This could be a Reddit API issue (service is down? buggy??) or an issue with the Bot account. Will not run heartbeat operations and will wait until next heartbeat (${dayjs.duration(this.nextHeartbeat.diff(dayjs())).humanize()}) to try again`);
}
await this.checkModInvites();
await this.buildManagers();
await this.heartbeat();
await this.checkModInvites();
} catch (err: any) {
this.logger.error(`Error occurred during heartbeat check: ${err.message}`);
}
@@ -930,73 +770,6 @@ class Bot implements BotInstanceFunctions {
this.emitter.emit('healthStopped');
}
getApiUsageSummary() {
const depletion = this.apiEstDepletion === undefined ? 'Not Calculated' : this.apiEstDepletion.humanize();
return`API Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${depletion} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`;
}
async apiHealthCheck(time?: number) {
const rollingSample = this.apiSample.slice(0, 7)
rollingSample.unshift(this.client.ratelimitRemaining);
this.apiSample = rollingSample;
const diff = this.apiSample.reduceRight((acc: number[], curr, index) => {
if (this.apiSample[index + 1] !== undefined) {
const d = Math.abs(curr - this.apiSample[index + 1]);
if (d === 0) {
return [...acc, 0];
}
return [...acc, d / 10];
}
return acc;
}, []);
const diffTotal = diff.reduce((acc, curr) => acc + curr, 0);
if(diffTotal === 0 || diff.length === 0) {
this.apiRollingAvg = 0;
} else {
this.apiRollingAvg = diffTotal / diff.length; // api requests per second
}
this.depletedInSecs = this.apiRollingAvg === 0 ? Number.POSITIVE_INFINITY : this.client.ratelimitRemaining / this.apiRollingAvg; // number of seconds until current remaining limit is 0
// if depletion/api usage is 0 we need a sane value to use here for both displaying in logs as well as for api nanny. 10 years seems reasonable
this.apiEstDepletion = dayjs.duration((this.depletedInSecs === Number.POSITIVE_INFINITY ? {years: 10} : {seconds: this.depletedInSecs}));
if(this.influxClients.length > 0) {
const apiMeasure = new Point('apiHealth')
.intField('remaining', this.client.ratelimitRemaining)
.stringField('nannyMod', this.nannyMode ?? 'none');
if(time !== undefined) {
apiMeasure.timestamp(time);
}
if(this.apiSample.length > 1) {
const curr = this.apiSample[0];
const last = this.apiSample[1];
if(curr <= last) {
apiMeasure.intField('used', last - curr);
}
}
for(const iclient of this.influxClients) {
await iclient.writePoint(apiMeasure);
}
}
}
async guestModCleanup() {
const now = dayjs();
for(const m of this.subManagers) {
const expiredGuests = m.managerEntity.getGuests().filter(x => x.expiresAt.isBefore(now));
if(expiredGuests.length > 0) {
m.managerEntity.removeGuestById(expiredGuests.map(x => x.id));
m.logger.info(`Removed expired Guest Mods: ${expiredGuests.map(x => x.author.name).join(', ')}`);
await this.managerRepo.save(m.managerEntity);
}
}
}
async retentionCleanup() {
const now = dayjs();
if(now.isSameOrAfter(this.nextRetentionCheck)) {
@@ -1010,8 +783,15 @@ class Bot implements BotInstanceFunctions {
}
async heartbeat() {
this.logger.info(`HEARTBEAT -- ${this.getApiUsageSummary()}`);
const heartbeat = `HEARTBEAT -- API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ~${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion === undefined ? 'N/A' : this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`
this.logger.info(heartbeat);
// run sanity check to see if there is a service issue
try {
await this.testClient(false);
} catch (err: any) {
throw new SimpleError(`Something isn't right! This could be a Reddit API issue (service is down? buggy??) or an issue with the Bot account. Will not run heartbeat operations and will wait until next heartbeat (${dayjs.duration(this.nextHeartbeat.diff(dayjs())).humanize()}) to try again`);
}
let startedAny = false;
for (const s of this.subManagers) {
@@ -1064,7 +844,6 @@ class Bot implements BotInstanceFunctions {
async runApiNanny() {
try {
this.logger.debug(this.getApiUsageSummary());
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
const nowish = dayjs().add(10, 'second');
if (nowish.isAfter(this.nextExpiration)) {
@@ -1088,12 +867,30 @@ class Bot implements BotInstanceFunctions {
}
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
}
const rollingSample = this.apiSample.slice(0, 7)
rollingSample.unshift(this.client.ratelimitRemaining);
this.apiSample = rollingSample;
const diff = this.apiSample.reduceRight((acc: number[], curr, index) => {
if (this.apiSample[index + 1] !== undefined) {
const d = Math.abs(curr - this.apiSample[index + 1]);
if (d === 0) {
return [...acc, 0];
}
return [...acc, d / 10];
}
return acc;
}, []);
this.apiRollingAvg = diff.reduce((acc, curr) => acc + curr, 0) / diff.length; // api requests per second
this.depletedInSecs = this.client.ratelimitRemaining / this.apiRollingAvg; // number of seconds until current remaining limit is 0
this.apiEstDepletion = dayjs.duration({seconds: this.depletedInSecs});
this.logger.debug(`API Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`);
let hardLimitHit = false;
if (typeof this.hardLimit === 'string' && this.apiEstDepletion !== undefined) {
if (typeof this.hardLimit === 'string') {
const hardDur = parseDuration(this.hardLimit);
hardLimitHit = hardDur.asSeconds() > this.apiEstDepletion.asSeconds();
} else if(typeof this.hardLimit === 'number') {
} else {
hardLimitHit = this.hardLimit > this.client.ratelimitRemaining;
}
@@ -1102,6 +899,7 @@ class Bot implements BotInstanceFunctions {
return;
}
this.logger.info(`Detected HARD LIMIT of ${this.hardLimit} remaining`, {leaf: 'Api Nanny'});
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${this.apiRollingAvg}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
this.logger.info(`All subreddit event polling has been paused`, {leaf: 'Api Nanny'});
for (const m of this.subManagers) {
@@ -1118,10 +916,10 @@ class Bot implements BotInstanceFunctions {
}
let softLimitHit = false;
if (typeof this.softLimit === 'string' && this.apiEstDepletion !== undefined) {
if (typeof this.softLimit === 'string') {
const softDur = parseDuration(this.softLimit);
softLimitHit = softDur.asSeconds() > this.apiEstDepletion.asSeconds();
} else if(typeof this.softLimit === 'number') {
} else {
softLimitHit = this.softLimit > this.client.ratelimitRemaining;
}
@@ -1130,6 +928,7 @@ class Bot implements BotInstanceFunctions {
return;
}
this.logger.info(`Detected SOFT LIMIT of ${this.softLimit} remaining`, {leaf: 'Api Nanny'});
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
this.logger.info('Trying to detect heavy usage subreddits...', {leaf: 'Api Nanny'});
let threshold = 0.5;
let offenders = this.subManagers.filter(x => {
@@ -1187,251 +986,6 @@ class Bot implements BotInstanceFunctions {
throw err;
}
}
getManagerNames(): string[] {
return this.subManagers.map(x => x.displayLabel);
}
getSubreddits(normalized = true): string[] {
return normalized ? this.subManagers.map(x => parseRedditEntity(x.subreddit.display_name).name) : this.subManagers.map(x => x.subreddit.display_name);
}
getGuestManagers(user: string): NormalizedManagerResponse[] {
return this.subManagers.filter(x => x.managerEntity.getGuests().map(y => y.author.name).includes(user)).map(x => x.toNormalizedManager());
}
getGuestSubreddits(user: string): string[] {
return this.getGuestManagers(user).map(x => x.subredditNormal);
}
getAccessibleSubreddits(user: string, subreddits: string[] = []): string[] {
const normalSubs = subreddits.map(x => parseRedditEntity(x).name);
const moderatedSubs = intersect(normalSubs, this.getSubreddits());
const guestSubs = this.getGuestSubreddits(user);
return Array.from(new Set([...guestSubs, ...moderatedSubs]));
}
canUserAccessBot(user: string, subreddits: string[] = []) {
return this.getAccessibleSubreddits(user, subreddits).length > 0;
}
canUserAccessSubreddit(subreddit: string, user: string, subreddits: string[] = []): boolean {
return this.getAccessibleSubreddits(user, subreddits).includes(parseRedditEntity(subreddit).name);
}
async addGuest(userVal: string | string[], expiresAt: Dayjs, managerVal?: string | string[]) {
let managerNames: string[];
if(typeof managerVal === 'string') {
managerNames = [managerVal];
} else if(Array.isArray(managerVal)) {
managerNames = managerVal;
} else {
managerNames = this.subManagers.map(x => x.subreddit.display_name);
}
const cleanSubredditNames = managerNames.map(x => parseRedditEntity(x).name);
const userNames = typeof userVal === 'string' ? [userVal] : userVal;
const cleanUsers = userNames.map(x => parseRedditEntity(x.trim(), 'user').name);
const users: AuthorEntity[] = [];
for(const uName of cleanUsers) {
let user = await this.authorRepo.findOne({
where: {
name: uName,
}
});
if(user === null) {
users.push(await this.authorRepo.save(new AuthorEntity({name: uName})));
} else {
users.push(user);
}
}
const newGuestData = users.map(x => ({author: x, expiresAt})) as GuestEntityData[];
let newGuests = new Map<string, Guest[]>();
const updatedManagerEntities: ManagerEntity[] = [];
for(const m of this.subManagers) {
if(!cleanSubredditNames.includes(m.subreddit.display_name)) {
continue;
}
const filteredGuests = m.managerEntity.addGuest(newGuestData);
updatedManagerEntities.push(m.managerEntity);
newGuests.set(m.displayLabel, filteredGuests.map(x => guestEntityToApiGuest(x)));
m.logger.info(`Added ${cleanUsers.join(', ')} as Guest`);
}
await this.managerRepo.save(updatedManagerEntities);
return newGuests;
}
async removeGuest(userVal: string | string[], managerVal?: string | string[]) {
let managerNames: string[];
if(typeof managerVal === 'string') {
managerNames = [managerVal];
} else if(Array.isArray(managerVal)) {
managerNames = managerVal;
} else {
managerNames = this.subManagers.map(x => x.subreddit.display_name);
}
const cleanSubredditNames = managerNames.map(x => parseRedditEntity(x).name);
const userNames = typeof userVal === 'string' ? [userVal] : userVal;
const cleanUsers = userNames.map(x => parseRedditEntity(x.trim(), 'user').name);
let newGuests = new Map<string, Guest[]>();
const updatedManagerEntities: ManagerEntity[] = [];
for(const m of this.subManagers) {
if(!cleanSubredditNames.includes(m.subreddit.display_name)) {
continue;
}
const filteredGuests = m.managerEntity.removeGuestByUser(cleanUsers);
updatedManagerEntities.push(m.managerEntity);
newGuests.set(m.displayLabel, filteredGuests.map(x => guestEntityToApiGuest(x)));
m.logger.info(`Removed ${cleanUsers.join(', ')} from Guests`);
}
await this.managerRepo.save(updatedManagerEntities);
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;

View File

@@ -1,18 +1,19 @@
import {RuleSet, RuleSetConfigData, RuleSetConfigHydratedData, RuleSetConfigObject} from "../Rule/RuleSet";
import {Rule} from "../Rule";
import Action, {ActionConfig} from "../Action";
import {RuleSet, IRuleSet, RuleSetJson, RuleSetObjectJson, isRuleSetJSON} from "../Rule/RuleSet";
import {IRule, Rule, RuleJSONConfig} from "../Rule";
import Action, {ActionConfig, ActionJson, StructuredActionJson} from "../Action";
import {Logger} from "winston";
import {Comment, Submission} from "snoowrap";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {actionFactory} from "../Action/ActionFactory";
import {ruleFactory} from "../Rule/RuleFactory";
import {
asPostBehaviorOptionConfig,
createAjvFactory,
FAIL,
isRuleSetResult,
boolToString,
createAjvFactory, determineNewResults,
FAIL, isRuleSetResult,
mergeArr,
PASS,
resultsSummary,
ruleNamesFromResults,
truncateStringToLength
} from "../util";
import {
@@ -21,17 +22,19 @@ import {
CheckSummary,
JoinCondition,
NotificationEventPayload,
PostBehavior,
PostBehaviorOptionConfigStrong,
PostBehaviorStrong,
RuleSetResult
PostBehavior, PostBehaviorOptionConfig, PostBehaviorOptionConfigStrong, PostBehaviorStrong,
RuleResult,
RuleSetResult, UserResultCache
} from "../Common/interfaces";
import * as RuleSchema from '../Schema/Rule.json';
import * as RuleSetSchema from '../Schema/RuleSet.json';
import * as ActionSchema from '../Schema/Action.json';
import {SubredditResources} from "../Subreddit/SubredditResources";
import {
ActionJson as ActionTypeJson
} from "../Common/types";
import {SubredditResources} from "../Subreddit/SubredditResources";
import {ExtendedSnoowrap} from '../Utils/SnoowrapClients';
import {ActionProcessingError, CheckProcessingError} from "../Utils/Errors";
import {ActionProcessingError, CheckProcessingError, isRateLimitError} from "../Utils/Errors";
import {ErrorWithCause, stackWithCauses} from "pony-cause";
import {runCheckOptions} from "../Subreddit/Manager";
import EventEmitter from "events";
@@ -44,28 +47,24 @@ import {RunnableBase} from "../Common/RunnableBase";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {RuleSetResultEntity} from "../Common/Entities/RuleSetResultEntity";
import {CheckToRuleResultEntity} from "../Common/Entities/RunnableAssociation/CheckToRuleResultEntity";
import {JoinOperands, PostBehaviorType, RecordOutputType, recordOutputTypes} from "../Common/Infrastructure/Atomic";
import {CommentState, SubmissionState,} from "../Common/Infrastructure/Filters/FilterCriteria";
import {
JoinOperands,
PostBehaviorType,
RecordOutputType,
recordOutputTypes
} from "../Common/Infrastructure/Atomic";
import {
MinimalOrFullFilter,
MinimalOrFullFilterJson
} from "../Common/Infrastructure/Filters/FilterShapes";
import {
CommentState,
SubmissionState,
} from "../Common/Infrastructure/Filters/FilterCriteria";
import {ActivityType} from "../Common/Infrastructure/Reddit";
import {
RunnableBaseJson,
RunnableBaseOptions,
StructuredRunnableBase,
TypedRunnableBaseData, TypedStructuredRunnableBase
} from "../Common/Infrastructure/Runnable";
import {
RuleConfigData, RuleConfigHydratedData,
RuleConfigObject,
StructuredRuleConfigObject,
StructuredRuleSetConfigObject
} from "../Common/Infrastructure/RuleShapes";
import {
ActionConfigData,
ActionConfigHydratedData,
ActionConfigObject,
StructuredActionObjectJson
} from "../Common/Infrastructure/ActionShapes";
import {IncludesData} from "../Common/Infrastructure/Includes";
import {RunnableBaseJson, RunnableBaseOptions, StructuredRunnableBase} from "../Common/Infrastructure/Runnable";
import {RuleJson, StructuredRuleObjectJson, StructuredRuleSetObjectJson} from "../Common/Infrastructure/RuleShapes";
import {ActionObjectJson, StructuredActionObjectJson} from "../Common/Infrastructure/ActionShapes";
const checkLogName = truncateStringToLength(25);
@@ -156,7 +155,7 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
if(asPostBehaviorOptionConfig(postFail)) {
const {
behavior = 'next',
recordTo = ['influx']
recordTo = false
} = postFail;
let recordStrong: RecordOutputType[] = [];
if(typeof recordTo === 'boolean') {
@@ -175,7 +174,7 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
} else {
this.postFail = {
behavior: postFail,
recordTo: ['influx']
recordTo: []
}
}
@@ -193,12 +192,12 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
let ruleErrors: any = [];
if (valid) {
const ruleConfig = r;
this.rules.push(new RuleSet({...ruleConfig as StructuredRuleSetConfigObject, logger: this.logger, subredditName, resources: this.resources, client: this.client}));
this.rules.push(new RuleSet({...ruleConfig as StructuredRuleSetObjectJson, logger: this.logger, subredditName, resources: this.resources, client: this.client}));
} else {
setErrors = ajv.errors;
valid = ajv.validate(RuleSchema, r);
if (valid) {
this.rules.push(ruleFactory(r as StructuredRuleConfigObject, this.logger, subredditName, this.resources, this.client));
this.rules.push(ruleFactory(r as StructuredRuleObjectJson, this.logger, subredditName, this.resources, this.client));
} else {
ruleErrors = ajv.errors;
const leastErrorType = setErrors.length < ruleErrors ? 'RuleSet' : 'Rule';
@@ -222,14 +221,7 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
this.actions.push(actionFactory({
...aj,
dryRun: this.dryRun || aj.dryRun
}, {
logger: this.logger,
subredditName,
resources: this.resources,
client: this.client,
emitter: this.emitter,
checkName: this.name
}));
}, this.logger, subredditName, this.resources, this.client, this.emitter));
// @ts-ignore
a.logger = this.logger;
} else {
@@ -442,7 +434,7 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
checkSum.postBehavior = this.postFail.behavior;
}
behaviorT = checkResult.triggered ? 'Trigger' : 'Fail';
behaviorT = checkSum.triggered ? 'Trigger' : 'Fail';
switch (checkSum.postBehavior.toLowerCase()) {
case 'next':
@@ -571,7 +563,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, runActions, options);
const res = await a.handle(item, ruleResults, options);
runActions.push(res);
}
this.logger.info(`${dr ? 'DRYRUN - ' : ''}Ran Actions: ${runActions.map(x => x.premise.getFriendlyIdentifier()).join(' | ')}`);
@@ -613,7 +605,7 @@ export interface ICheck extends JoinCondition, PostBehavior, RunnableBaseJson {
}
export interface CheckOptions extends Omit<ICheck, 'authorIs' | 'itemIs'>, RunnableBaseOptions {
rules: Array<RuleConfigObject | RuleSetConfigObject>;
rules: Array<StructuredRuleSetObjectJson | StructuredRuleObjectJson>;
actions: ActionConfig[];
logger: Logger;
subredditName: string;
@@ -624,15 +616,7 @@ export interface CheckOptions extends Omit<ICheck, 'authorIs' | 'itemIs'>, Runna
emitter: EventEmitter
}
/*
* Can contain actions/rules as:
* - full objects
* - string to hydrate IE "url:fsdfd"
* - named string IE "namedRule"
*
* Also can contain itemIs/authorIs as full object or named filter
* */
export interface CheckConfigData extends ICheck, RunnableBaseJson {
export interface CheckJson extends ICheck {
/**
* The type of event (new submission or new comment) this check should be run against
* @examples ["submission", "comment"]
@@ -647,7 +631,7 @@ export interface CheckConfigData extends ICheck, RunnableBaseJson {
*
* **If `rules` is an empty array or not present then `actions` are performed immediately.**
* */
rules?: (RuleSetConfigData | RuleConfigData | string | IncludesData)[]
rules?: Array<RuleSetJson | RuleJson>
/**
* The `Actions` to run after the check is successfully triggered. ALL `Actions` will run in the order they are listed
*
@@ -655,7 +639,7 @@ export interface CheckConfigData extends ICheck, RunnableBaseJson {
*
* @examples [[{"kind": "comment", "content": "this is the content of the comment", "distinguish": true}, {"kind": "lock"}]]
* */
actions?: ActionConfigData[]
actions?: Array<ActionTypeJson>
/**
* If notifications are configured and this is `true` then an `eventActioned` event will be sent when this check is triggered.
@@ -667,49 +651,9 @@ export interface CheckConfigData extends ICheck, RunnableBaseJson {
cacheUserResult?: UserResultCacheOptions;
}
export interface SubmissionCheckConfigData extends CheckConfigData, TypedRunnableBaseData<SubmissionState> {
export interface SubmissionCheckJson extends CheckJson {
kind: 'submission'
}
export interface CommentCheckConfigData extends CheckConfigData, TypedRunnableBaseData<CommentState> {
kind: 'comment'
}
/*
* Can contain actions/rules as:
* - full objects
* - named string IE "namedRule"
*
* Also can contain itemIs/authorIs as full object or named filter
* */
export interface CheckConfigHydratedData extends CheckConfigData {
rules?: (RuleSetConfigHydratedData | RuleConfigHydratedData)[]
actions?: ActionConfigHydratedData[]
}
export interface SubmissionCheckConfigHydratedData extends CheckConfigHydratedData, TypedRunnableBaseData<SubmissionState> {
kind: 'submission'
}
export interface CommentCheckConfigHydratedData extends CheckConfigHydratedData, TypedRunnableBaseData<CommentState> {
kind: 'comment'
}
/*
* All actions/rules/filters should now be full objects
* */
export interface CheckConfigObject extends Omit<CheckConfigHydratedData, 'itemIs' | 'authorIs'>, StructuredRunnableBase {
rules: Array<RuleSetConfigObject | RuleConfigObject>
actions: Array<ActionConfigObject>
}
export interface SubmissionCheckConfigObject extends Omit<CheckConfigObject, 'itemIs' | 'author'>, TypedStructuredRunnableBase<SubmissionState> {
kind: 'submission'
}
export interface CommentCheckConfigObject extends Omit<CheckConfigObject, 'itemIs' | 'author'>, TypedStructuredRunnableBase<CommentState> {
kind: 'comment'
itemIs?: MinimalOrFullFilterJson<SubmissionState>
}
/**
@@ -747,18 +691,33 @@ export const userResultCacheDefault: Required<UserResultCacheOptions> = {
runActions: true,
}
export const asStructuredCommentCheckJson = (val: any): val is CommentCheckConfigObject => {
export interface CommentCheckJson extends CheckJson {
kind: 'comment'
itemIs?: MinimalOrFullFilterJson<CommentState>
}
export const asStructuredCommentCheckJson = (val: any): val is CommentCheckStructuredJson => {
return val.kind === 'comment';
}
export const asStructuredSubmissionCheckJson = (val: any): val is SubmissionCheckConfigObject => {
export const asStructuredSubmissionCheckJson = (val: any): val is SubmissionCheckStructuredJson => {
return val.kind === 'submission';
}
export type ActivityCheckConfigValue = string | IncludesData | SubmissionCheckConfigData | CommentCheckConfigData;
export type CheckStructuredJson = SubmissionCheckStructuredJson | CommentCheckStructuredJson;
// export interface CheckStructuredJson extends CheckJson {
// rules: Array<RuleSetObjectJson | RuleObjectJson>
// actions: Array<ActionObjectJson>
// }
export type ActivityCheckConfigData = Exclude<ActivityCheckConfigValue, IncludesData>;
export interface SubmissionCheckStructuredJson extends Omit<SubmissionCheckJson, 'authorIs' | 'itemIs' | 'rules'>, StructuredRunnableBase {
rules: Array<StructuredRuleSetObjectJson | StructuredRuleObjectJson>
actions: Array<ActionObjectJson>
itemIs?: MinimalOrFullFilter<SubmissionState>
}
export type ActivityCheckConfigHydratedData = SubmissionCheckConfigHydratedData | CommentCheckConfigHydratedData;
export type ActivityCheckObject = SubmissionCheckConfigObject | CommentCheckConfigObject;
export interface CommentCheckStructuredJson extends Omit<CommentCheckJson, 'authorIs' | 'itemIs' | 'rules'>, StructuredRunnableBase {
rules: Array<StructuredRuleSetObjectJson | StructuredRuleObjectJson>
actions: Array<ActionObjectJson>
itemIs?: MinimalOrFullFilter<CommentState>
}

View File

@@ -1,34 +0,0 @@
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();
}
}

View File

@@ -1,210 +0,0 @@
import {CacheProvider} from "../Infrastructure/Atomic";
import {CacheOptions, StrongTTLConfig} from "../interfaces";
import {cacheOptDefaults} from "../defaults";
import cacheManager, {Cache, CachingConfig, WrapArgsType} from "cache-manager";
import redisStore from "cache-manager-redis-store";
import {create as createMemoryStore} from "../../Utils/memoryStore";
import winston, {Logger} from "winston";
import {mergeArr, parseStringToRegex, redisScanIterator} from "../../util";
import globrex from "globrex";
import objectHash from "object-hash";
export const buildCacheOptionsFromProvider = (provider: CacheProvider | any): CacheOptions => {
if (typeof provider === 'string') {
return {
store: provider as CacheProvider,
...cacheOptDefaults
}
}
return {
store: 'memory',
...cacheOptDefaults,
...provider,
}
}
export const createCacheManager = (options: CacheOptions): Cache => {
const {store, max, ttl = 60, host = 'localhost', port, auth_pass, db, prefix, ...rest} = options;
switch (store) {
case 'none':
return cacheManager.caching({store: 'none', max, ttl});
case 'redis':
return cacheManager.caching({
store: redisStore,
host,
port,
auth_pass,
db,
ttl,
...rest,
});
case 'memory':
default:
//return cacheManager.caching({store: 'memory', max, ttl});
return cacheManager.caching({store: {create: createMemoryStore}, max, ttl, shouldCloneBeforeSet: false});
}
}
export class CMCache {
pruneInterval?: any;
prefix?: string
cache: Cache
isDefaultCache: boolean
defaultPrefix?: string
providerOptions: CacheOptions;
logger!: Logger;
constructor(cache: Cache, providerOptions: CacheOptions, defaultCache: boolean, defaultPrefix: string | undefined, ttls: Partial<StrongTTLConfig>, logger: Logger) {
this.cache = cache;
this.providerOptions = providerOptions
this.isDefaultCache = defaultCache;
this.prefix = this.providerOptions.prefix ?? '';
this.defaultPrefix = defaultPrefix ?? '';
this.setLogger(logger);
this.setPruneInterval(ttls);
}
setLogger(logger: Logger) {
this.logger = logger.child({labels: ['Cache']}, mergeArr);
}
equalProvider(candidate: CacheOptions) {
return objectHash.sha1(candidate) === objectHash.sha1(this.providerOptions);
}
setPruneInterval(ttls: Partial<StrongTTLConfig>) {
if (this.providerOptions.store === 'memory' && !this.isDefaultCache) {
if (this.pruneInterval !== undefined) {
clearInterval(this.pruneInterval);
}
const min = Math.min(60, ...Object.values(ttls).filter(x => typeof x === 'number' && x !== 0) as number[]);
if (min > 0) {
// set default prune interval
this.pruneInterval = setInterval(() => {
// @ts-ignore
this.cache?.store.prune();
this.logger.debug('Pruned cache');
// prune interval should be twice the smallest TTL
}, min * 1000 * 2)
}
}
}
async getCacheKeyCount() {
if (this.cache.store.keys !== undefined) {
if (this.providerOptions.store === 'redis') {
const keys = await this.cache.store.keys(`${this.prefix}*`);
return keys.length;
}
return (await this.cache.store.keys()).length;
}
return 0;
}
async interactWithCacheByKeyPattern(pattern: string | RegExp, action: 'get' | 'delete') {
let patternIsReg = pattern instanceof RegExp;
let regPattern: RegExp;
let globPattern = pattern;
const cacheDict: Record<string, any> = {};
if (typeof pattern === 'string') {
const possibleRegPattern = parseStringToRegex(pattern, 'ig');
if (possibleRegPattern !== undefined) {
regPattern = possibleRegPattern;
patternIsReg = true;
} else {
if (this.prefix !== undefined && !pattern.includes(this.prefix)) {
// need to add wildcard to beginning of pattern so that the regex will still match a key with a prefix
globPattern = `${this.prefix}${pattern}`;
}
// @ts-ignore
const result = globrex(globPattern, {flags: 'i'});
regPattern = result.regex;
}
} else {
regPattern = pattern;
}
if (this.providerOptions.store === 'redis') {
// @ts-ignore
const redisClient = this.cache.store.getClient();
if (patternIsReg) {
// scan all and test key by regex
for await (const key of redisClient.scanIterator()) {
if (regPattern.test(key) && (this.prefix === undefined || key.includes(this.prefix))) {
if (action === 'delete') {
await redisClient.del(key)
} else {
cacheDict[key] = await redisClient.get(key);
}
}
}
} else {
// not a regex means we can use glob pattern (more efficient!)
for await (const key of redisScanIterator(redisClient, {MATCH: globPattern})) {
if (action === 'delete') {
await redisClient.del(key)
} else {
cacheDict[key] = await redisClient.get(key);
}
}
}
} else if (this.cache.store.keys !== undefined) {
for (const key of await this.cache.store.keys()) {
if (regPattern.test(key) && (this.prefix === undefined || key.includes(this.prefix))) {
if (action === 'delete') {
await this.cache.del(key)
} else {
cacheDict[key] = await this.cache.get(key);
}
}
}
}
return cacheDict;
}
async deleteCacheByKeyPattern(pattern: string | RegExp) {
return await this.interactWithCacheByKeyPattern(pattern, 'delete');
}
async getCacheByKeyPattern(pattern: string | RegExp) {
return await this.interactWithCacheByKeyPattern(pattern, 'get');
}
get store() {
return this.cache.store;
}
del(key: string, shared = false): Promise<any> {
return this.cache.del(`${shared ? this.defaultPrefix : this.prefix}${key}`);
}
get<T>(key: string, shared = false): Promise<T | undefined> {
return this.cache.get(`${shared ? this.defaultPrefix : this.prefix}${key}`);
}
reset(): Promise<void> {
return this.cache.reset();
}
set<T>(key: string, value: T, options?: CachingConfig & {shared?: boolean}): Promise<T> {
const {shared = false} = options || {};
return this.cache.set(`${shared ? this.defaultPrefix : this.prefix}${key}`, value, options);
}
wrap<T>(...args: WrapArgsType<T>[]): Promise<T> {
const options: any = args.length >= 3 ? args[2] : {};
const {shared = false} = options || {};
args[0] = `${shared ? this.defaultPrefix : this.prefix}${args[0]}`;
return this.cache.wrap(...args);
}
async destroy() {
if (this.pruneInterval !== undefined && this.providerOptions.store === 'memory' && !this.isDefaultCache) {
clearInterval(this.pruneInterval);
this.cache?.reset();
}
}
}

View File

@@ -6,5 +6,4 @@ export interface ConfigToObjectOptions {
location?: string,
jsonDocFunc?: (content: string, location?: string) => AbstractConfigDocument<OperatorJsonConfig>,
yamlDocFunc?: (content: string, location?: string) => AbstractConfigDocument<YamlDocument>
allowArrays?: boolean
}

View File

@@ -19,7 +19,6 @@ export const parseFromJsonOrYamlToObject = (content: string, options?: ConfigToO
location,
jsonDocFunc = (content: string, location?: string) => new JsonConfigDocument(content, location),
yamlDocFunc = (content: string, location?: string) => new YamlConfigDocument(content, location),
allowArrays = false,
} = options || {};
try {

View File

@@ -1,14 +1,11 @@
import YamlConfigDocument from "../YamlConfigDocument";
import JsonConfigDocument from "../JsonConfigDocument";
import {YAMLMap, YAMLSeq, Pair, Scalar} from "yaml";
import {BotInstanceJsonConfig, OperatorJsonConfig, WebCredentials} from "../../interfaces";
import {YAMLMap, YAMLSeq} from "yaml";
import {BotInstanceJsonConfig, OperatorJsonConfig} from "../../interfaces";
import {assign} from 'comment-json';
export interface OperatorConfigDocumentInterface {
addBot(botData: BotInstanceJsonConfig): void;
setFriendlyName(name: string): void;
setWebCredentials(data: Required<WebCredentials>): void;
setOperator(name: string): void;
toJS(): OperatorJsonConfig;
}
@@ -18,12 +15,10 @@ export class YamlOperatorConfigDocument extends YamlConfigDocument implements Op
if (bots === undefined) {
this.parsed.add({key: 'bots', value: [botData]});
} else if (botData.name !== undefined) {
// granularly overwrite (merge) if we find an existing
// overwrite if we find an existing
const existingIndex = bots.items.findIndex(x => (x as YAMLMap).get('name') === botData.name);
if (existingIndex !== -1) {
const botObj = this.parsed.getIn(['bots', existingIndex]) as YAMLMap;
const mergedVal = mergeObjectToYaml(botData, botObj);
this.parsed.setIn(['bots', existingIndex], mergedVal);
this.parsed.setIn(['bots', existingIndex], botData);
} else {
this.parsed.addIn(['bots'], botData);
}
@@ -32,41 +27,11 @@ export class YamlOperatorConfigDocument extends YamlConfigDocument implements Op
}
}
setFriendlyName(name: string) {
this.parsed.addIn(['api', 'friendly'], name);
}
setWebCredentials(data: Required<WebCredentials>) {
this.parsed.addIn(['web', 'credentials'], data);
}
setOperator(name: string) {
this.parsed.addIn(['operator', 'name'], name);
}
toJS(): OperatorJsonConfig {
return super.toJS();
}
}
export const mergeObjectToYaml = (source: object, target: YAMLMap) => {
for (const [k, v] of Object.entries(source)) {
if (target.has(k)) {
const targetProp = target.get(k);
if (targetProp instanceof YAMLMap && typeof v === 'object') {
const merged = mergeObjectToYaml(v, targetProp);
target.set(k, merged)
} else {
// since target prop and value are not both objects don't bother merging, just overwrite (primitive or array)
target.set(k, v);
}
} else {
target.add({key: k, value: v});
}
}
return target;
}
export class JsonOperatorConfigDocument extends JsonConfigDocument implements OperatorConfigDocumentInterface {
addBot(botData: BotInstanceJsonConfig) {
if (this.parsed.bots === undefined) {
@@ -83,23 +48,6 @@ export class JsonOperatorConfigDocument extends JsonConfigDocument implements Op
}
}
setFriendlyName(name: string) {
const api = this.parsed.api || {};
this.parsed.api = {...api, friendly: name};
}
setWebCredentials(data: Required<WebCredentials>) {
const {
web = {},
} = this.parsed;
this.parsed.web = {...web, credentials: data};
}
setOperator(name: string) {
this.parsed.operator = { name };
}
toJS(): OperatorJsonConfig {
return super.toJS();
}

View File

@@ -56,11 +56,6 @@ 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) {

Some files were not shown because too many files have changed in this diff Show More