Compare commits

..

81 Commits

Author SHA1 Message Date
FoxxMD
d02d70ded3 Merge branch 'edge' 2022-10-17 15:33:15 -04:00
FoxxMD
acbb9a8626 feat(usernote): Improve usernote filtering functionality
Same as modActions...

* Add `referencesCurrentActivity` boolean to filter by notes associated with current activity
* Add `note` string property to filter by note content (string or regular expression)
* Replace `allowDuplicates` with `existingNoteCheck` on UserNoteAction to allow for more granular note control on action
2022-10-17 15:32:47 -04:00
FoxxMD
122d5fb2af docs: Fix malformed URLs
Fixes #114
2022-10-17 14:16:34 -04:00
FoxxMD
98a8568eb6 fix: Add missing else condition 2022-10-13 16:17:43 -04:00
FoxxMD
457f947603 fix: Further improvements for influxdb logging
* Suppress write failure warnings to reduce noise in log
  * Can be toggled using debug flag in config
2022-10-13 12:34:11 -04:00
FoxxMD
7fb69ae67a fix: Attempt to decrease frequency of influxdb timeout errors
* Use keep-alive http agent to reuse open connections
* Decrease max batch size and flush interval to payload sent is smaller
* Add debug logging for fail/success/retry events on flush
2022-10-13 11:39:45 -04:00
FoxxMD
2241d40e49 fix: Fix custom footer never loading
Custom footer needs to be loaded AFTER resources are set
2022-10-13 08:58:12 -04:00
FoxxMD
a3ca3f17ec refactor(recent): Log image parsing error with cause 2022-10-12 14:39:21 -04:00
FoxxMD
f527a17fa2 feat(modnote): Implement existing note check before adding note to replace allowDuplicates
* Use "modActions" authorIs filtering to check note prior to adding note using "existingNoteCheck" property
  * Refactors concept of "allowDuplicates" to allow any arbitrary modActions test to be used
* Provide convenience ModLogCriteria generation for "existingNoteCheck" based on boolean (emulates allowDuplicates functionality)
2022-10-12 13:00:47 -04:00
FoxxMD
e98364eae9 feat(filter): Improve modAction filtering functionality
* Implement filtering activityType by "false" in order to return actions/notes not added to a specific activity
* Implement "referencesCurrentActivity" property to allow filtering by actions/notes that are associated with the activity being processed
* Implement using "count" for "current" search to enable criteria condition based on presence or non-presence of current action/note passing
2022-10-12 12:58:49 -04:00
FoxxMD
8b125d7433 feat(ui): Improve visibility and resilience for live log stream
* Tie loading indicator to live stream status and display error if one occurs
  * Add manual restart action to end of error
* Restart stream automatically if reader ends, up to 3 retries
2022-10-11 12:19:55 -04:00
FoxxMD
6ee060c5ce fix(logs): Remove listeners from log stream event emitter before end of response to prevent write-after-end errors 2022-10-11 12:18:00 -04:00
FoxxMD
9b12d0b2b3 feat(bot): Improve log wording for manager loading phase 2022-10-11 10:51:00 -04:00
FoxxMD
b174c7928a feat(ui): Add favicon files 2022-10-11 10:49:57 -04:00
FoxxMD
74dfe9258a fix(filter): Fix mod action note filtering assignment 2022-10-10 15:00:39 -04:00
FoxxMD
1cf8855a24 feat(testing): Implement initial author filter tests 2022-10-10 12:06:08 -04:00
FoxxMD
adc69894fc fix(filter): Fix detecting empty filter when using 'replace' filter default behavior 2022-10-10 11:03:57 -04:00
FoxxMD
3435c683c8 feat(filter): Add author flair item criteria 2022-10-05 11:24:44 -04:00
FoxxMD
80f83bf84b Merge branch 'edge' 2022-10-05 08:57:04 -04:00
FoxxMD
f0032cd433 Bump version 2022-10-05 08:56:53 -04:00
FoxxMD
7933f77764 Merge branch 'edge' 2022-10-05 08:55:39 -04:00
FoxxMD
ade0b7948e Add patreon funding link 2022-10-04 09:40:03 -04:00
FoxxMD
542aa26c62 feat(history): Implement ratio comparison #112 2022-10-03 13:25:05 -04:00
FoxxMD
3faf4ca3dc fix: Fix wiki location usage when getting from subreddit resources 2022-09-28 10:34:48 -04:00
FoxxMD
2f35b82d5e feat(history): Log failure results for easier rule tuning 2022-09-28 10:25:26 -04:00
FoxxMD
3bcc3d78e8 Merge branch 'edge' 2022-09-28 09:28:38 -04:00
FoxxMD
c9d8bf637b chore: Add tests for item criteria 2022-09-26 16:59:46 -04:00
FoxxMD
027f4087e3 refactor: Improve activity source parse and comparison
* Implement a DTO class for activity source to make parts usage (type, identifier) and matching easier
* Implement regex to parse type and identifier from activity source string
* Refactor activity source interface/types to better distinguish as string, data, and class
2022-09-26 12:07:24 -04:00
FoxxMD
1b20122ffc docs: Fix some typos 2022-09-23 10:35:13 -04:00
FoxxMD
5d53571ec0 fix: Fix typo in default footer
Thanks u/SampleOfNone for catching that
2022-09-22 10:59:57 -04:00
FoxxMD
b3df1b4d41 Update default confidence for MHS based on feedback from Welton 2022-09-20 10:56:39 -04:00
FoxxMD
4abe8e07f3 docs: Add docs for MHS toxic content prediction rule 2022-09-20 10:44:08 -04:00
FoxxMD
9bb95106ba feat(mhs): Add default confidence threshold 2022-09-20 09:35:38 -04:00
FoxxMD
02414478bf feat: Implement rule that tests activity content against moderatehatespeech.com ML model using api #110
* Add mhs rule type and MHS credentials interface
* Implement MHS rule with similar criteria options to sentiment
* Allow testing against author history content
2022-09-19 16:20:35 -04:00
FoxxMD
8b0a582464 fix(recent): Empty viable activity list if processing activity is required to be reference but is not a valid type 2022-09-19 11:46:20 -04:00
FoxxMD
d1db5f4688 fix: Fix subreddit filtering result when no subreddit criteria are present
Should return all items instead of none. Thanks u/SampleOfNone for helping track this down with examples.
2022-09-19 10:01:39 -04:00
FoxxMD
44f9389b69 docs: Add general subreddit/moderator docs readme
* Also add Guest Access docs
2022-09-16 16:40:28 -04:00
FoxxMD
71b2d0597d docs: Update screenshots 2022-09-16 15:38:52 -04:00
FoxxMD
57cfcebe9f fix: Set default invites to empty array on heartbeat 2022-09-16 14:34:28 -04:00
FoxxMD
07ecc505ff fix: Check bot entity exists before getting invites 2022-09-16 14:29:34 -04:00
FoxxMD
81213686ce Merge branch 'subredditInviteUI' into edge
# Conflicts:
#	src/Subreddit/SubredditResources.ts
2022-09-16 13:31:37 -04:00
FoxxMD
08735d505a refactor(manager): Improve wiki CRUD and onboarding initial config
* Refactor write/read into separate functions
* Improve error hinting for wiki read/write/permissions WRT mod/oauth permissions
* Remove superfluous error wrapping to reduce logging length for wiki errors
* More debug logging for onboarding process
* Don't return error if manager fails to parse after all onboarding complete (not critical)
2022-09-16 13:27:41 -04:00
FoxxMD
1a62c752c1 feat(ui): Improve finished onboarding response
* don't redirect on error
* increase delay to redirect on success
2022-09-16 13:24:09 -04:00
FoxxMD
6aa7367297 fix(client): Improve responses dependent on server information
* run initHeartbeat on any GET route that render a page so that user doesn't get access denied on initial app load
* Force client refresh if no invite found on initial check for onboarding landing
2022-09-16 13:22:44 -04:00
FoxxMD
cf9583227c feat: Add subredditExists function to snoowrap client
Reddit returns 403 if the subreddit exists but is private. Using this function wraps the error so we can just get boolean back along with subreddit object, if successful
2022-09-16 13:20:07 -04:00
FoxxMD
aa505ba3f2 feat: Improve external resource fetching in subreddit resources
* Add "default" hint to force val to a url or wiki key if neither is detected but know it should be one of them
* Refactor wiki/url fetching into own functions for better reuseability
* Implement mod permission getter function to check for valid permissions on wiki page error
* Improve error hints on wiki page read failure
2022-09-16 13:19:23 -04:00
FoxxMD
a0182d89ca feat: util function for generating full wiki urls 2022-09-16 13:16:26 -04:00
FoxxMD
d46f0a5be8 fix: Improve error detection for log transform
Check for error name OR stack existence
2022-09-16 13:16:06 -04:00
FoxxMD
4a55d35e14 feat(filter): Rename createdAt to createdOn
Better naming
2022-09-15 13:27:41 -04:00
FoxxMD
1284051fe8 fix(filter): Fix copy-past typo for createdAt 2022-09-15 11:24:53 -04:00
FoxxMD
00680494a3 feat(filter): Add createdAt activity criteria matching
* May be a string or array of strings. Passes if any expression matches
  * Value may be a convience day-of-week value (mon, tues, wed...)
  * or a cron expression
2022-09-15 11:22:04 -04:00
FoxxMD
296f1c8dee Merge branch 'edge' 2022-09-14 15:30:27 -04:00
FoxxMD
77856a6d97 chore: Bump version 2022-09-14 15:30:12 -04:00
FoxxMD
e32ac60db5 Merge branch 'edge' 2022-09-14 15:29:13 -04:00
FoxxMD
b216cd08e1 Merge branch 'edge' into subredditInviteUI 2022-09-14 15:27:14 -04:00
FoxxMD
052c1218c6 feat(ui): Add home logo 2022-09-14 15:26:59 -04:00
FoxxMD
c2343683bb Finish implementation accept flow 2022-09-14 15:12:33 -04:00
FoxxMD
fcf718f1d0 fix(ui): Fix typo in reddit status indicator switch 2022-09-14 13:21:48 -04:00
FoxxMD
77f848007a Implement subreddit onboarding accept process
* Refactor some manager/bot methods to be more accessible for invites/wiki
* Add routes and authentication for getting invite information and checking user is moderator of subreddit
* Add route for accept invite and completing onboarding guest/config
2022-09-14 13:20:53 -04:00
FoxxMD
95216b3950 docs: Add GHCR image location to install docs
Closes #109
2022-09-13 15:06:13 -04:00
FoxxMD
58a21e8d05 Scope required permissions for github token 2022-09-13 14:35:11 -04:00
FoxxMD
49ac8cda19 feat: Update github actions to push to multiple registries
And add documentation for GH actions local dev/testing
2022-09-13 14:23:22 -04:00
FoxxMD
bc8be3608b Use methods for bot entity subreddit invite crud 2022-09-13 10:33:55 -04:00
FoxxMD
1b69cd78bb Refactor subreddit invites to use db and add interface
* Refactor to use db instead of cache for persisting invites
* Implement subreddit invite helper page
* Add initial config and guests as optional data for invite
* Refactor bot to use db subreddit invite and auto-accept when no config/guests

TODO: subreddit accept page, mod authorization, initial config usage, and documentation
2022-09-12 16:29:41 -04:00
FoxxMD
e736379f85 feat(ui): Improve guest expiration interface
* Set default time to 24 hours
* Add label and tooltip for expiration datetime picker
2022-09-07 11:57:29 -04:00
FoxxMD
c0e1a93fb4 fix(config): Use correct filter defaults data structure for runs
Should use "json" data structure type so named filters can be used in defaults on runs
2022-09-06 11:18:24 -04:00
FoxxMD
bd35b06ebf fix(filter): Fix missed toLowerCase for named filter getter 2022-09-02 17:41:28 -04:00
FoxxMD
f852e85234 feat(author): authorIs 'name' criteria may be regular expression 2022-09-02 16:21:53 -04:00
FoxxMD
661ae11e18 refactor(ui): Replace websockets opStats with client delta polling #106
* Refactor/remove websockets functionality for relaying opstats from server with direct polling by client
* Implement delta responses initially introduced in #91 to reduce bandwidth
2022-09-01 16:01:37 -04:00
FoxxMD
859680dca8 Merge branch 'edge' 2022-09-01 09:03:27 -04:00
FoxxMD
18c1ac0fd7 Chore: Bump version 2022-09-01 09:03:12 -04:00
FoxxMD
2fb503f09f feat(message): Implement templating for to field so subreddit can be used generically
since 'to' be now be templated a user can configure a message to send to the subreddit the action is being processed from using `to: 'r/{{item.subreddit}}'`
2022-08-31 09:58:24 -04:00
FoxxMD
7e5eeb71da fix(submission): Fix targets data type to accept array 2022-08-31 09:31:25 -04:00
FoxxMD
84f2da8b6d feat(action): Implement Action templating data #104
* Add top-level 'actionSummary' template variable that renders a markdown list of action results
* Add individual action result/data, in the same structure as rules, under the top-level 'actions' template variable
2022-08-30 17:12:51 -04:00
FoxxMD
be51f8ae43 fix(image): Fix hash result test assertion 2022-08-30 16:13:35 -04:00
FoxxMD
f82d985eab feat(template): Refactor templating to be more extensible and add some requested data
* Implement interfaces for template parts
* Refactor Action constructor to take an object for runtime options (cleaner, more extensible)
* Refactor subreddit resource and snoowraputils content rendering and organization to use objects of optional data rather than required arguments
  * Make almost all data optional and only parse/render if included
  * Move rule results parsing/formatting into own function
  * Refactor footer render to use renderContent (DRY)
* Add some requested template data #104
  * {{item.subreddit}} #87
  * {{check}} #87
* Updated templating documentation
2022-08-26 13:42:02 -04:00
FoxxMD
aab014650a fix(template): Fix rule data spread to template 2022-08-26 11:22:59 -04:00
FoxxMD
113ac3e10e fix(template): Fix subreddit breakdown reference override 2022-08-25 17:14:28 -04:00
FoxxMD
afb6aad26d fix(template): Fix subreddit breakdown when empty activities array 2022-08-25 16:30:15 -04:00
FoxxMD
33b60825d9 fix(template): Return empty string if no subreddits in breakdown 2022-08-25 16:16:15 -04:00
FoxxMD
b5202a33ac feat(template): Add subreddit breakdown to template data for Recent and History rule 2022-08-25 10:39:03 -04:00
126 changed files with 7974 additions and 1323 deletions

View File

@@ -13,7 +13,7 @@ coverage
*.json5
*.yaml
*.yml
*.env
# exceptions
!heroku.yml

1
.github/FUNDING.yml vendored
View File

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

3
.github/push-hook-sample.json vendored Normal file
View File

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

View File

@@ -1,4 +1,14 @@
name: Publish Docker image to Dockerhub
name: Publish Docker image to registries
# Builds image and tags based on the type of push event:
# * branch push -> tag is branch name IE context-mod:edge
# * release (tag) -> tag is release name IE context-mod:0.13.0
#
# Then pushes tagged images to multiple registries
#
# Based on
# https://github.com/docker/build-push-action/blob/master/docs/advanced/push-multi-registries.md
# https://github.com/docker/metadata-action
on:
push:
@@ -13,8 +23,12 @@ on:
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
name: Build and Push Docker image to registries
runs-on: ubuntu-latest
# https://docs.github.com/en/actions/security-guides/automatic-token-authentication#permissions-for-the-github_token
permissions:
packages: write
contents: read
steps:
- name: Check out the repo
uses: actions/checkout@v2
@@ -25,12 +39,22 @@ jobs:
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
with:
images: foxxmd/context-mod
images: |
foxxmd/context-mod
ghcr.io/foxxmd/context-mod
# generate Docker tags based on the following events/attributes
tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
@@ -40,7 +64,8 @@ jobs:
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v2
if: ${{ !env.ACT }}
uses: docker/build-push-action@v3
with:
context: .
push: ${{ github.event_name != 'pull_request' }}

2
.gitignore vendored
View File

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

View File

@@ -31,6 +31,8 @@ Feature Highlights for **Moderators:**
* [**Web interface**](#web-ui-and-screenshots) for monitoring, administration, and oauth bot authentication
* [**Placeholders**](/docs/subreddit/actionTemplating.md) (like automoderator) can be configured via a wiki page or raw text and supports [mustache](https://mustache.github.io) templating
* [**Partial Configurations**](/docs/subreddit/components/README.md#partial-configurations) -- offload parts of your configuration to shared locations to consolidate logic between multiple subreddits
* [Guest Access](/docs/subreddit/README.md#guest-access) enables collaboration and easier setup by allowing temporary access
* [Toxic content prediction](/docs/subreddit/components/README.md#moderatehatespeechcom-predictions) using [moderatehatespeech.com](https://moderatehatespeech.com) machine learning model
Feature highlights for **Developers and Hosting (Operators):**
@@ -134,6 +136,10 @@ Moderator view/invite and authorization:
![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:

3
act.env.example Normal file
View File

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

View File

@@ -152,6 +152,7 @@ An **Action** is some action the bot can take against the checked Activity (comm
* For **Operator/Bot maintainers** see **[Operation Guide](/docs/operator/README.md)**
* For **Moderators**
* Start with the [Subreddit/Moderator docs](/docs/subreddit/README.md) or [Moderator Getting Started guide](/docs/subreddit/gettingStarted.md)
* Refer to the [Subreddit Components Documentation](/docs/subreddit/components) or the [subreddit-ready examples](/docs/subreddit/components/subredditReady)
* as well as the [schema](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) which has
* fully annotated configuration data/structure

View File

@@ -1,5 +1,17 @@
TODO add more development sections...
# Developing/Testing Github Actions
Use [act](https://github.com/nektos/act) to run Github actions locally.
An example secrets file can be found in the project working directory at [act.env.example](act.env.example)
Modify [push-hook-sample.json](.github/push-hook-sample.json) to point to the local branch you want to run a `push` event trigger on, then run this command from the project working directory:
```bash
act -e .github/push-hook-sample.json --secret-file act.env
```
# Mocking Reddit API
Using [MockServer](https://www.mock-server.com/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 133 KiB

BIN
docs/images/guests.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 479 KiB

After

Width:  |  Height:  |  Size: 225 KiB

View File

@@ -8,7 +8,10 @@ ContextMod can be run on almost any operating system but it is recommended to us
PROTIP: Using a container management tool like [Portainer.io CE](https://www.portainer.io/products/community-edition) will help with setup/configuration tremendously.
### [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod)
Images available from these registeries:
* [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod) - `docker.io/foxxmd/context-mod`
* [GHCR](https://github.com/foxxmd/context-mod/pkgs/container/context-mod) - `ghcr.io/foxxmd/context-mod`
An example of starting the container using the [minimum configuration](/docs/operator/configuration.md#minimum-config):
@@ -17,7 +20,7 @@ An example of starting the container using the [minimum configuration](/docs/ope
* Expose the web interface using the container port `8085`
```
docker run -d -v /host/path/folder:/config -p 8085:8085 foxxmd/context-mod
docker run -d -v /host/path/folder:/config -p 8085:8085 ghcr.io/foxxmd/context-mod:latest
```
The location of `DATA_DIR` in the container can be changed by passing it as an environmental variable EX `-e "DATA_DIR=/home/abc/config`
@@ -34,7 +37,7 @@ To get the UID and GID for the current user run these commands from a terminal:
* `id -g` -- prints GID
```
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 foxxmd/context-mod
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 ghcr.io/foxxmd/context-mod:latest
```
## Locally

95
docs/subreddit/README.md Normal file
View File

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

View File

@@ -1,12 +1,32 @@
Actions that can submit text (Report, Comment, UserNote) will have their `content` values run through a [Mustache Template](https://mustache.github.io/). This means you can insert data generated by Rules into your text before the Action is performed.
Actions that can submit text (Report, Comment, UserNote, Message, Ban, Submission) will have their `content` values run through a [Mustache Template](https://mustache.github.io/). This means you can insert data generated by Rules into your text before the Action is performed.
See here for a [cheatsheet](https://gist.github.com/FoxxMD/d365707cf99fdb526a504b8b833a5b78) and [here](https://www.tsmean.com/articles/mustache/the-ultimate-mustache-tutorial/) for a more thorough tutorial.
# Template Data
Some data can always be accessed at the top-level. Example
```
This action was run from {{manager}} in Check {{check}}.
The bot intro post is {{botLink}}
Message the moderators of this subreddit using this [compose link]({{modmailLink}})
```
| Name | Description | Example |
|---------------|---------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
| `manager` | The name of the subreddit the bot is running in | mealtimevideos |
| `check` | The name of the Check that was triggered | myCheck |
| `botLink` | A link to the bot introduction | https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot |
| `modmailLink` | A link that opens reddit's DM compose with the subject line as the Activity being processed | https://www.reddit.com/message/compose?to=/r/mealtimevideos&message=https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot |
## Activity Data
Activity data can be accessed using the `item` variable. Example
**Activity data can be accessed using the `item` variable.** Example
```
This activity is a {{item.kind}} with {{item.votes}} votes, created {{item.age}} ago.
@@ -19,14 +39,17 @@ Produces:
All Actions with `content` have access to this data:
| Name | Description | Example |
|-------------|-----------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------|
| `kind` | The Activity type (submission or comment) | submission |
| `author` | Name of the Author of the Activity being processed | FoxxMD |
| `permalink` | URL to the Activity | https://reddit.com/r/mySuibreddit/comments/ab23f/my_post |
| `votes` | Number of upvotes | 69 |
| `age` | The age of the Activity in a [human friendly format](https://day.js.org/docs/en/durations/humanize) | 5 minutes |
| `botLink` | A URL to CM's introduction thread | https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot |
| Name | Description | Example |
|--------------|-----------------------------------------------------------------------------------------------------|----------------------------------------------------------|
| `kind` | The Activity type (submission or comment) | submission |
| `author` | Name of the Author of the Activity being processed | FoxxMD |
| `permalink` | URL to the Activity | https://reddit.com/r/mySuibreddit/comments/ab23f/my_post |
| `votes` | Number of upvotes | 69 |
| `age` | The age of the Activity in a [human friendly format](https://day.js.org/docs/en/durations/humanize) | 5 minutes |
| `subreddit` | The name of the subreddit the Activity is from | mealtimevideos |
| `id` | The `Reddit Thing` ID for the Activity | t3_0tin1 |
| `title` | As comments => the body of the comment. As Submission => title | Test post please ignore |
| `shortTitle` | The same as `title` but truncated to 15 characters | test post pleas... |
### Submissions
@@ -89,7 +112,39 @@ Produces
> Submission was repeated 7 times
#### Quick Templating Tutorial
## Action Data
### Summary
A summary of what actions have already been run **when the template is rendered** is available using the `actionSummary` variable. It is therefore important that the Action you want to produce the summary is run **after** any other Actions you want to get a summary for.
Example:
```
A summary of actions processed for this activity, so far:
{{actionSummary}}
```
Would produce:
> A summary of actions processed for this activity, so far:
>
> * approve - ✘ - Item is already approved??
> * lock - ✓
> * modnote - ✓ - (SOLID_CONTRIBUTOR) User is good
### Individual
Individual **Actions** can be accessed using the name of the action, **lower-cased, with all spaces/dashes/underscores.** Example:
```
User was banned for {{actions.exampleban.duration}} for {{actions.exampleban.reason}}
```
Produces
> User was banned for 4 days for toxic behavior
# Quick Templating Tutorial
As a quick example for how you will most likely be using templating -- wrapping a variable in curly brackets, `{{variable}}`, will cause the variable value to be rendered instead of the brackets:

View File

@@ -22,6 +22,7 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
* [Regex](#regex)
* [Repost](#repost)
* [Sentiment Analysis](#sentiment-analysis)
* [Toxic Content Prediction](#moderatehatespeechcom-predictions)
* [Rule Sets](#rule-sets)
* [Actions](#actions)
* [Named Actions](#named-actions)
@@ -377,6 +378,12 @@ This rule is for searching **all of Reddit** for reposts, as opposed to just the
The **Sentiment Rule** is used to determine the overall emotional intent (negative, neutral, positive) of a Submission or Comment by analyzing the actual text content of the Activity.
### ModerateHateSpeech.com Predictions
[**Full Documentation**](/docs/subreddit/components/mhs)
ContextMod integrates with [moderatehatespeech.com](https://moderatehatespeech.com/) (MHS) [toxic content machine learning model](https://moderatehatespeech.com/framework/) through their API. This rule sends an Activity's content (title or body) to MHS which returns a prediction on whether the content is toxic and actionable by a moderator. Their model is [specifically trained for reddit content.](https://www.reddit.com/r/redditdev/comments/xdscbo/updated_bot_backed_by_moderationoriented_ml_for/)
# Rule Sets
The `rules` list on a `Check` can contain both `Rule` objects and `RuleSet` objects.
@@ -692,15 +699,16 @@ Some other things to note:
* If the `to` property is not specified then the message is sent to the Author of the Activity being processed
* `to` may be a **User** (u/aUser) or a **Subreddit** (r/aSubreddit)
* `to` **cannot** be a Subreddit when `asSubreddit: true` -- IE cannot send subreddit-to-subreddit messages
* `content` can be [templated](#templating) and use [URL Tokens](#url-tokens)
* TIP: `to` can be templated -- to send a message to the subreddit the Activity being processed is in use `'r/{{item.subreddit}}'`
* `content` and `title` can be [templated](#templating) and use [URL Tokens](#url-tokens)
```yaml
actions:
- kind: message
asSubreddit: true
content: 'A message sent as the subreddit'
title: 'Title of the message'
to: 'u/aUser' # do not specify 'to' in order default to sending to Author of Activity being processed
content: 'A message sent as the subreddit' # can be templated
title: 'Title of the message' # can be templated
to: 'u/aUser' # do not specify 'to' in order default to sending to Author of Activity being processed. Can also be templated
```
### Remove
@@ -746,7 +754,7 @@ actions:
- kind: usernote
type: spamwarn
content: 'Usernote message'
allowDuplicate: boolean # if false then the usernote will not be added if the same note appears for this activity
existingNoteCheck: boolean # if true (default) then the usernote will not be added if the same note appears for this activity
```
### Mod Note
@@ -771,6 +779,7 @@ actions:
type: SPAM_WATCH
content: 'a note only mods can see message' # optional
referenceActivity: boolean # if true the Note will be linked to the Activity being processed
existingNoteCheck: boolean # if true (default) then the note will not be added if the same note appears for this activity
```
# Filters

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
// AND = any of the nested Rules will be the Rule Set trigger
// OR = any of the nested Rules will be the Rule Set trigger
"condition": "AND",
// in this check we use an Attribution >10% on ONLY submissions, which is a lower requirement then the above attribution rule
// and combine it with a History rule looking for low comment engagement

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
# AND = any of the nested Rules will be the Rule Set trigger
# OR = any of the nested Rules will be the Rule Set trigger
- condition: AND
# in this check we use an Attribution >10% on ONLY submissions, which is a lower requirement then the above attribution rule
# and combine it with a History rule looking for low comment engagement

View File

@@ -10,5 +10,5 @@ Consult the [schema](https://json-schema.app/view/%23/%23%2Fdefinitions%2FCheckJ
### Examples
* 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 all Activities [YAML](/docs/subreddit/components/attribution/redditSelfPromoAll.yaml) | [JSON](/docs/subreddit/components/attribution/redditSelfPromoAll.json5) - Check if Author is submitting much more than they comment.
* Self Promotion as percentage of Submissions [YAML](/docs/subreddit/components/attribution/redditSelfPromoSubmissionsOnly.yaml) | [JSON](/docs/examplesm/attribution/redditSelfPromoSubmissionsOnly.json5) - Check if any of Author's aggregated submission origins are >10% of their submissions

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

View File

@@ -5,10 +5,46 @@ The **History** rule can check an Author's submission/comment statistics over a
* Submission total or percentage of All Activity
* Comment total or percentage of all Activity
* Comments made as OP (commented in their own Submission) total or percentage of all Comments
* Ratio of activities against another window of activities
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FHistoryJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
## Ratio
Use the `ratio` property in Criteria to test the [number of activities](/docs/subreddit/activitiesWindow.md) found in the parent criteria against the number of activities from _another_ [activity window](/docs/subreddit/activitiesWindow.md) defined in the ratio.
Example:
```yaml
- kind: history
criteria:
# "parent" criteria, returns all activities, in the last 100 from user's history, that occurred in r/mealtimevideos
- window:
count: 100
filterOn:
post:
subreddits:
include:
- mealtimevideos
ratio:
# "ratio" criteria, returns all activities, in the last 100 from user's history, that occurred in r/redditdev
window:
count: 100
filterOn:
post:
subreddits:
include:
- redditdev
# test (number of parent criteria activities) / (number of ratio critieria activities)
threshold: '> 1.2'
```
`threshold` may be a number or percentage `(number * 100)`
* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities
* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities
### Examples
* Low Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/lowEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/lowEngagement.json5) - Check if Author is submitting much more than they comment.
* OP Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content
* Low Comment Engagement [YAML](/docs/subreddit/components/history/lowEngagement.yaml) | [JSON](/docs/subreddit/components/history/lowEngagement.json5) - Check if Author is submitting much more than they comment.
* OP Comment Engagement [YAML](/docs/subreddit/components/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/components/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content

View File

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

View File

@@ -27,5 +27,5 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRecentActi
### Examples
* Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits
* Submission in Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently
* Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits
* Submission in Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently

View File

@@ -11,12 +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/componentscomponents/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchAnyCurrentActivity.json5)
* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.json5)
* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.json5)
* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.json5)
* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5)
* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.json5)
* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.json5)
* Remove comments that are spamming discord links - [YAML](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.json5)
* Trigger if regex matches against the current activity - [YAML](/docs/subreddit/components/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchAnyCurrentActivity.json5)
* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/components/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchThresholdCurrentActivity.json5)
* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/components/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/components/regex/matchSubmissionParts.json5)
* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchHistoryActivity.json5)
* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5)
* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchTotalHistoryActivity.json5)
* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/components/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchSubsetHistoryActivity.json5)
* Remove comments that are spamming discord links - [YAML](/docs/subreddit/components/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/components/regex/removeDiscordSpam.json5)
* Differs from just using automod because this config can allow one-off/organic links from users who DO NOT spam discord links but will still remove the comment if the user is spamming them

View File

@@ -47,5 +47,5 @@ With only `gapAllowance: 2` this rule **would trigger** because the the 1 and 2
## Examples
* Crosspost Spamming [JSON](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits
* Burst-posting [JSON](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts
* Crosspost Spamming [JSON](/docs/subreddit/components/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/components/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits
* Burst-posting [JSON](/docs/subreddit/components/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/components/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts

View File

@@ -17,25 +17,25 @@ All actions for these configurations are non-destructive in that:
### 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)
[YAML](/docs/subreddit/components/subredditReady/freekarma.yaml) | [JSON](/docs/subreddit/components/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)
[YAML](/docs/subreddit/components/subredditReady/crosspostSpam.yaml) | [JSON](/docs/subreddit/components/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)
[YAML](/docs/subreddit/componentsc/subredditReady/freeKarmaOrCrosspostSpam.yaml) | [JSON](/docs/subreddit/components/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)
[YAML](/docs/subreddit/components/subredditReady/selfPromo.yaml) | [JSON](/docs/subreddit/components/subredditReady/selfPromo.json5)
If the link origin (youtube author, twitter author, etc. or regular domain for non-media links)
@@ -48,13 +48,13 @@ then remove the submission
### 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)
[YAML](/docs/subreddit/components/subredditReady/commentSpam.yaml) | [JSON](/docs/subreddit/components/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)
[YAML](/docs/subreddit/components/subredditReady/discordSpam.yaml) | [JSON](/docs/subreddit/components/subredditReady/discordSpam.json5)
This rule goes a step further than automod can by being more discretionary about how it handles this type of spam.

View File

@@ -43,7 +43,7 @@
// remove this after confirming behavior is acceptable
{
"kind": "report",
"content": "Remove=> {{rules.newtube.totalCount}} activities in freekarma subs"
"content": "Remove=> {{rules.freekarma.totalCount}} activities in freekarma subs"
},
//
//

View File

@@ -25,7 +25,7 @@ runs:
actions:
- kind: report
enable: true
content: 'Remove=> {{rules.newtube.totalCount}} activities in freekarma subs'
content: 'Remove=> {{rules.freekarma.totalCount}} activities in freekarma subs'
- kind: remove
enable: true
- kind: comment

View File

@@ -24,7 +24,7 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteCr
### Examples
* Do not tag user with Good User note [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.yaml)
* Do not tag user with Good User note [JSON](/docs/subreddit/components/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteFilter.yaml)
## Action
@@ -33,4 +33,4 @@ A User Note can also be added to the Author of a Submission or Comment with the
### Examples
* Add note on user doing self promotion [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteSP.yaml)
* Add note on user doing self promotion [JSON](/docs/subreddit/components/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteSP.yaml)

70
package-lock.json generated
View File

@@ -11,9 +11,10 @@
"license": "ISC",
"dependencies": {
"@awaitjs/express": "^0.8.0",
"@datasert/cronjs-matcher": "^1.2.0",
"@googleapis/youtube": "^2.0.0",
"@influxdata/influxdb-client": "^1.27.0",
"@influxdata/influxdb-client-apis": "^1.27.0",
"@influxdata/influxdb-client": "^1.31.0",
"@influxdata/influxdb-client-apis": "^1.31.0",
"@nlpjs/core": "^4.23.4",
"@nlpjs/lang-de": "^4.23.4",
"@nlpjs/lang-en": "^4.23.4",
@@ -656,6 +657,20 @@
"kuler": "^2.0.0"
}
},
"node_modules/@datasert/cronjs-matcher": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@datasert/cronjs-matcher/-/cronjs-matcher-1.2.0.tgz",
"integrity": "sha512-ht6Vwwa3qssMn/9bphypjG/U8w0DV3GtTS2C6kbAy39rerQFTRzmml9xZNlot1K13gm9K/EEq3DLPEOsH++ICw==",
"dependencies": {
"@datasert/cronjs-parser": "^1.2.0",
"luxon": "^2.1.1"
}
},
"node_modules/@datasert/cronjs-parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@datasert/cronjs-parser/-/cronjs-parser-1.2.0.tgz",
"integrity": "sha512-7kzYh7F5V3ElX+k3W9w6SKS6WdjqJQ2gIY1y0evldnjAwZxnFzR/Yu9Mv9OeDaCQX+mGAq2MvEnJbwu9oj3CXQ=="
},
"node_modules/@googleapis/youtube": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@googleapis/youtube/-/youtube-2.0.0.tgz",
@@ -668,14 +683,14 @@
}
},
"node_modules/@influxdata/influxdb-client": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.27.0.tgz",
"integrity": "sha512-hOBi+ApIurDd8jFWo+eYjMWWsDRp3wih/U/NOVRoHaTOE8ihSQthi9wfMD4YeVqt4pCN6ygIwo7lEKFXwNuwcA=="
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz",
"integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA=="
},
"node_modules/@influxdata/influxdb-client-apis": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.27.0.tgz",
"integrity": "sha512-a4gd7CwNRXSsSVt9tm8GzGxuPXngEmQucMdoTZ0YYeWSbKUXz3B/3u9/EqMGEbtq5MdbbB2OKA611hu205UiNg==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz",
"integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==",
"peerDependencies": {
"@influxdata/influxdb-client": "*"
}
@@ -5796,6 +5811,14 @@
"node": ">=10"
}
},
"node_modules/luxon": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.0.tgz",
"integrity": "sha512-IDkEPB80Rb6gCAU+FEib0t4FeJ4uVOuX1CQ9GsvU3O+JAGIgu0J7sf1OarXKaKDygTZIoJyU6YdZzTFRu+YR0A==",
"engines": {
"node": ">=12"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@@ -10812,6 +10835,20 @@
"kuler": "^2.0.0"
}
},
"@datasert/cronjs-matcher": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@datasert/cronjs-matcher/-/cronjs-matcher-1.2.0.tgz",
"integrity": "sha512-ht6Vwwa3qssMn/9bphypjG/U8w0DV3GtTS2C6kbAy39rerQFTRzmml9xZNlot1K13gm9K/EEq3DLPEOsH++ICw==",
"requires": {
"@datasert/cronjs-parser": "^1.2.0",
"luxon": "^2.1.1"
}
},
"@datasert/cronjs-parser": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@datasert/cronjs-parser/-/cronjs-parser-1.2.0.tgz",
"integrity": "sha512-7kzYh7F5V3ElX+k3W9w6SKS6WdjqJQ2gIY1y0evldnjAwZxnFzR/Yu9Mv9OeDaCQX+mGAq2MvEnJbwu9oj3CXQ=="
},
"@googleapis/youtube": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@googleapis/youtube/-/youtube-2.0.0.tgz",
@@ -10821,14 +10858,14 @@
}
},
"@influxdata/influxdb-client": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.27.0.tgz",
"integrity": "sha512-hOBi+ApIurDd8jFWo+eYjMWWsDRp3wih/U/NOVRoHaTOE8ihSQthi9wfMD4YeVqt4pCN6ygIwo7lEKFXwNuwcA=="
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz",
"integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA=="
},
"@influxdata/influxdb-client-apis": {
"version": "1.27.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.27.0.tgz",
"integrity": "sha512-a4gd7CwNRXSsSVt9tm8GzGxuPXngEmQucMdoTZ0YYeWSbKUXz3B/3u9/EqMGEbtq5MdbbB2OKA611hu205UiNg==",
"version": "1.31.0",
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz",
"integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==",
"requires": {}
},
"@istanbuljs/load-nyc-config": {
@@ -14886,6 +14923,11 @@
"yallist": "^4.0.0"
}
},
"luxon": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-2.5.0.tgz",
"integrity": "sha512-IDkEPB80Rb6gCAU+FEib0t4FeJ4uVOuX1CQ9GsvU3O+JAGIgu0J7sf1OarXKaKDygTZIoJyU6YdZzTFRu+YR0A=="
},
"make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",

View File

@@ -31,9 +31,10 @@
"license": "ISC",
"dependencies": {
"@awaitjs/express": "^0.8.0",
"@datasert/cronjs-matcher": "^1.2.0",
"@googleapis/youtube": "^2.0.0",
"@influxdata/influxdb-client": "^1.27.0",
"@influxdata/influxdb-client-apis": "^1.27.0",
"@influxdata/influxdb-client": "^1.31.0",
"@influxdata/influxdb-client-apis": "^1.31.0",
"@nlpjs/core": "^4.23.4",
"@nlpjs/lang-de": "^4.23.4",
"@nlpjs/lang-en": "^4.23.4",

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

View File

@@ -8,6 +8,7 @@ import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTarget, ActionTypes} from "../Common/Infrastructure/Atomic";
import {asComment, asSubmission} from "../util";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class ApproveAction extends Action {
@@ -26,7 +27,7 @@ export class ApproveAction extends Action {
this.targets = targets;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const touchedEntities = [];

View File

@@ -7,10 +7,18 @@ import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
import {truncateStringToLength} from "../util";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
const truncate = truncateStringToLength(100);
const truncateLongMessage = truncateStringToLength(200);
const truncateIfNotUndefined = (val: string | undefined) => {
if(val === undefined) {
return undefined;
}
return truncate(val);
}
export class BanAction extends Action {
message?: string;
@@ -39,13 +47,13 @@ export class BanAction extends Action {
return 'ban';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const renderedBody = this.message === undefined ? undefined : await this.resources.renderContent(this.message, item, ruleResults);
const renderedContent = renderedBody === undefined ? undefined : `${renderedBody}${await this.resources.generateFooter(item, this.footer)}`;
const renderedBody = await this.renderContent(this.message, item, ruleResults, actionResults);
const renderedContent = renderedBody === undefined ? undefined : `${renderedBody}${await this.resources.renderFooter(item, this.footer)}`;
const renderedReason = this.reason === undefined ? undefined : truncate(await this.resources.renderContent(this.reason, item, ruleResults));
const renderedNote = this.note === undefined ? undefined : truncate(await this.resources.renderContent(this.note, item, ruleResults));
const renderedReason = truncateIfNotUndefined(await this.renderContent(this.reason, item, ruleResults, actionResults) as string);
const renderedNote = truncateIfNotUndefined(await this.renderContent(this.note, item, ruleResults, actionResults) as string);
const touchedEntities = [];
let banPieces = [];
@@ -72,7 +80,13 @@ export class BanAction extends Action {
dryRun,
success: true,
result: `Banned ${item.author.name} ${durText}${renderedReason !== undefined ? ` (${renderedReason})` : ''}`,
touchedEntities
touchedEntities,
data: {
message: renderedContent === undefined ? undefined : renderedContent,
reason: renderedReason,
note: renderedNote,
duration: durText
}
};
}

View File

@@ -12,6 +12,7 @@ import {isSubmission, parseDurationValToDuration} from "../util";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTarget, ActionTypes, InclusiveActionTarget} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class CancelDispatchAction extends Action {
identifiers?: (string | null)[];
@@ -35,7 +36,7 @@ export class CancelDispatchAction extends Action {
this.targets = !Array.isArray(target) ? [target] : target;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
// see note in DispatchAction about missing runtimeDryrun
const dryRun = this.dryRun;

View File

@@ -9,6 +9,7 @@ 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";
export class CommentAction extends Action {
content: string;
@@ -44,12 +45,11 @@ export class CommentAction extends Action {
return 'comment';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const content = await this.resources.getContent(this.content, item.subreddit);
const body = await renderContent(content, item, ruleResults, this.resources.userNotes);
const body = await this.renderContent(this.content, item, ruleResults, actionResults) as string;
const footer = await this.resources.generateFooter(item, this.footer);
const footer = await this.resources.renderFooter(item, this.footer);
const renderedContent = `${body}${footer}`;
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
@@ -154,6 +154,12 @@ export class CommentAction extends Action {
success: !allErrors,
result: `${targetResults.join('\n')}${truncateStringToLength(100)(body)}`,
touchedEntities,
data: {
body,
bodyShort: truncateStringToLength(100)(body),
comments: targetResults,
commentsFormatted: targetResults.map(x => `* ${x}`).join('\n')
}
};
}

View File

@@ -7,6 +7,7 @@ import Comment from "snoowrap/dist/objects/Comment";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTarget, ActionTypes} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class ContributorAction extends Action {
@@ -25,7 +26,7 @@ export class ContributorAction extends Action {
this.actionType = action;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const contributors = await this.resources.getSubredditContributors();

View File

@@ -8,6 +8,7 @@ import {activityDispatchConfigToDispatch, isSubmission, parseDurationValToDurati
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTarget, ActionTypes} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class DispatchAction extends Action {
dispatchData: ActivityDispatchConfig;
@@ -39,7 +40,7 @@ export class DispatchAction extends Action {
this.targets = !Array.isArray(target) ? [target] : target;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
// ignore runtimeDryrun here because "real run" isn't causing any reddit api calls to happen
// -- basically if bot is in dryrun this should still run since we want the "full effect" of the bot
// BUT if the action explicitly sets 'dryRun: true' then do not dispatch as they probably don't want to it actually going (intention?)

View File

@@ -5,13 +5,14 @@ import {ActionProcessResult, RuleResult} from "../Common/interfaces";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class LockAction extends Action {
getKind(): ActionTypes {
return 'lock';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const touchedEntities = [];
//snoowrap typing issue, thinks comments can't be locked

View File

@@ -16,6 +16,7 @@ import {ErrorWithCause} from "pony-cause";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class MessageAction extends Action {
content: string;
@@ -48,28 +49,30 @@ export class MessageAction extends Action {
return 'message';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const body = await this.resources.renderContent(this.content, item, ruleResults);
const subject = this.title === undefined ? `Concerning your ${isSubmission(item) ? 'Submission' : 'Comment'}` : await this.resources.renderContent(this.title, item, ruleResults);
const body = await this.renderContent(this.content, item, ruleResults, actionResults);
const titleTemplate = this.title ?? `Concerning your ${isSubmission(item) ? 'Submission' : 'Comment'}`;
const subject = await this.renderContent(titleTemplate, item, ruleResults, actionResults) as string;
const footer = await this.resources.generateFooter(item, this.footer);
const footer = await this.resources.renderFooter(item, this.footer);
const renderedContent = `${body}${footer}`;
let recipient = item.author.name;
if(this.to !== undefined) {
const renderedTo = await this.renderContent(this.to, item, ruleResults, actionResults) as string;
// parse to value
try {
const entityData = parseRedditEntity(this.to, 'user');
const entityData = parseRedditEntity(renderedTo, 'user');
if(entityData.type === 'user') {
recipient = entityData.name;
} else {
recipient = `/r/${entityData.name}`;
}
} catch (err: any) {
throw new ErrorWithCause(`'to' field for message was not in a valid format. See ${REDDIT_ENTITY_REGEX_URL} for valid examples`, {cause: err});
throw new ErrorWithCause(`'to' field for message was not in a valid format, given value after templating: ${renderedTo} -- See ${REDDIT_ENTITY_REGEX_URL} for valid examples`, {cause: err});
}
if(recipient.includes('/r/') && this.asSubreddit) {
throw new SimpleError(`Cannot send a message as a subreddit to another subreddit. Requested recipient: ${recipient}`);
@@ -123,7 +126,7 @@ export interface MessageActionConfig extends RequiredRichContent, Footer {
asSubreddit: boolean
/**
* Entity to send message to.
* Entity to send message to. It can be templated.
*
* If not present Message be will sent to the Author of the Activity being checked.
*
@@ -135,8 +138,9 @@ export interface MessageActionConfig extends RequiredRichContent, Footer {
*
* **Note:** Reddit does not support sending a message AS a subreddit TO another subreddit
*
* @pattern ^\s*(\/[ru]\/|[ru]\/)*(\w+)*\s*$
* @examples ["aUserName","u/aUserName","r/aSubreddit"]
* **Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`
*
* @examples ["aUserName","u/aUserName","r/aSubreddit", "r/{{item.subreddit}}"]
* */
to?: string

View File

@@ -1,29 +1,32 @@
import {ActionJson, ActionConfig, ActionOptions} from "./index";
import Action from "./index";
import {Comment} from "snoowrap";
import {renderContent} from "../Utils/SnoowrapUtils";
import Submission from "snoowrap/dist/objects/Submission";
import {ActionProcessResult, RichContent} from "../Common/interfaces";
import {toModNoteLabel} from "../util";
import {buildFilterCriteriaSummary, normalizeModActionCriteria, toModNoteLabel} from "../util";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes, ModUserNoteLabel} from "../Common/Infrastructure/Atomic";
import {ModNote} from "../Subreddit/ModNotes/ModNote";
import {
ActionTypes,
ModUserNoteLabel,
} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {ModNoteCriteria} from "../Common/Infrastructure/Filters/FilterCriteria";
export class ModNoteAction extends Action {
content: string;
type?: string;
allowDuplicate: boolean;
existingNoteCheck?: ModNoteCriteria
referenceActivity: boolean
constructor(options: ModNoteActionOptions) {
super(options);
const {type, content = '', allowDuplicate = false, referenceActivity = true} = options;
const {type, content = '', existingNoteCheck = true, referenceActivity = true} = options;
this.type = type;
this.content = content;
this.allowDuplicate = allowDuplicate;
this.referenceActivity = referenceActivity;
this.existingNoteCheck = typeof existingNoteCheck === 'boolean' ? this.generateModLogCriteriaFromDuplicateConvenience(existingNoteCheck) : normalizeModActionCriteria(existingNoteCheck);
}
getKind(): ActionTypes {
@@ -34,41 +37,43 @@ export class ModNoteAction extends Action {
return {
content: this.content,
type: this.type,
allowDuplicate: this.allowDuplicate,
existingNoteCheck: this.existingNoteCheck,
referenceActivity: this.referenceActivity,
}
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const modLabel = this.type !== undefined ? toModNoteLabel(this.type) : undefined;
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
const renderedContent = await this.renderContent(this.content, item, ruleResults, actionResults);
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
// TODO see what changes are made for bulk fetch of notes before implementing this
// https://www.reddit.com/r/redditdev/comments/t8w861/new_mod_notes_api/
// if (!this.allowDuplicate) {
// const notes = await this.resources.userNotes.getUserNotes(item.author);
// let existingNote = notes.find((x) => x.link !== null && x.link.includes(item.id));
// if(existingNote === undefined && notes.length > 0) {
// const lastNote = notes[notes.length - 1];
// // possibly notes don't have a reference link so check if last one has same text
// if(lastNote.link === null && lastNote.text === renderedContent) {
// existingNote = lastNote;
// }
// }
// if (existingNote !== undefined && existingNote.noteType === this.type) {
// this.logger.info(`Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`);
// return {
// dryRun,
// success: false,
// result: `Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`
// };
// }
// }
let noteCheckPassed: boolean = true;
let noteCheckResult: undefined | string;
if(this.existingNoteCheck === undefined) {
// nothing to do!
noteCheckResult = 'existingNoteCheck=false so no existing note checks were performed.';
} else {
const noteCheckCriteriaResult = await this.resources.isAuthor(item, {
modActions: [this.existingNoteCheck]
});
noteCheckPassed = noteCheckCriteriaResult.passed;
const {details} = buildFilterCriteriaSummary(noteCheckCriteriaResult);
noteCheckResult = `${noteCheckPassed ? 'Existing note check condition succeeded' : 'Will not add note because existing note check condition failed'} -- ${details.join(' ')}`;
}
this.logger.info(noteCheckResult);
if (!noteCheckPassed) {
return {
dryRun,
success: false,
result: noteCheckResult
};
}
if (!dryRun) {
await this.resources.addModNote({
label: modLabel,
@@ -84,15 +89,36 @@ export class ModNoteAction extends Action {
result: `${modLabel !== undefined ? `(${modLabel})` : ''} ${renderedContent}`
}
}
generateModLogCriteriaFromDuplicateConvenience(val: boolean): ModNoteCriteria | undefined {
if(val) {
return {
noteType: this.type !== undefined ? [toModNoteLabel(this.type)] : undefined,
note: this.content !== '' ? [this.content] : undefined,
referencesCurrentActivity: this.referenceActivity ? true : undefined,
search: 'current',
count: '< 1'
}
}
return undefined;
}
}
export interface ModNoteActionConfig extends ActionConfig, RichContent {
/**
* Add Note even if a Note already exists for this Activity
* @examples [false]
* @default false
* Check if there is an existing Note matching some criteria before adding the Note.
*
* If this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.
*
* Boolean convenience:
*
* * If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria
* * If `false` then no check is performed and Note is always added
*
* @examples [true]
* @default true
* */
allowDuplicate?: boolean,
existingNoteCheck?: boolean | ModNoteCriteria,
type?: ModUserNoteLabel
referenceActivity?: boolean
}

View File

@@ -8,6 +8,7 @@ import {isSubmission, truncateStringToLength} from "../util";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
const truncate = truncateStringToLength(100);
export class RemoveAction extends Action {
@@ -31,7 +32,7 @@ export class RemoveAction extends Action {
this.reasonId = reasonId;
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const touchedEntities = [];
let removeSummary = [];
@@ -44,7 +45,7 @@ export class RemoveAction extends Action {
removeSummary.push('Marked as SPAM');
this.logger.verbose('Marking as spam on removal');
}
const renderedNote = this.note === undefined ? undefined : await this.resources.renderContent(this.note, item, ruleResults);
const renderedNote = await this.renderContent(this.note, item, ruleResults, actionResults);
let foundReasonId: string | undefined;
let foundReason: string | undefined;
@@ -99,7 +100,8 @@ export class RemoveAction extends Action {
return {
dryRun,
success: true,
touchedEntities
touchedEntities,
result: removeSummary.join(' | ')
}
}

View File

@@ -7,6 +7,7 @@ import {ActionProcessResult, RichContent, RuleResult} from "../Common/interfaces
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
// https://www.reddit.com/dev/api/oauth#POST_api_report
// denotes 100 characters maximum
@@ -25,10 +26,9 @@ export class ReportAction extends Action {
return 'report';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string);
this.logger.verbose(`Contents:\r\n${renderedContent}`);
const truncatedContent = reportTrunc(renderedContent);
const touchedEntities = [];

View File

@@ -10,6 +10,7 @@ import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infras
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;
@@ -67,21 +68,21 @@ export class SubmissionAction extends Action {
return 'submission';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const title = await this.resources.renderContent(this.title, item, ruleResults);
const title = await this.renderContent(this.title, item, ruleResults, actionResults) as string;
this.logger.verbose(`Title: ${title}`);
const url = this.url !== undefined ? await this.resources.renderContent(this.url, item, ruleResults) : undefined;
const url = await this.renderContent(this.url, item, ruleResults, actionResults);
this.logger.verbose(`URL: ${url !== undefined ? url : '[No URL]'}`);
const body = this.content !== undefined ? await this.resources.renderContent(this.content, item, ruleResults) : undefined;
const body = await this.renderContent(this.content, item, ruleResults, actionResults);
let renderedContent: string | undefined = undefined;
if(body !== undefined) {
const footer = await this.resources.generateFooter(item, this.footer);
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 {
@@ -204,6 +205,11 @@ export class SubmissionAction extends Action {
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')
}
};
}
@@ -309,7 +315,7 @@ export interface SubmissionActionConfig extends RichContent, Footer {
* * '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
targets?: ('self' | string) | ('self' | string)[]
}
export interface SubmissionActionOptions extends SubmissionActionConfig, ActionOptions {

View File

@@ -6,6 +6,7 @@ import Comment from 'snoowrap/dist/objects/Comment';
import {RuleResultEntity} from "../../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../../Subreddit/Manager";
import {ActionTypes} from "../../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../../Common/Entities/ActionResultEntity";
export class FlairAction extends Action {
text: string;
@@ -26,7 +27,7 @@ export class FlairAction extends Action {
return 'flair';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
let flairParts = [];
if(this.text !== '') {

View File

@@ -4,6 +4,7 @@ import {ActionProcessResult, RuleResult} from '../Common/interfaces';
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class UserFlairAction extends Action {
text?: string;
@@ -22,7 +23,7 @@ export class UserFlairAction extends Action {
return 'userflair';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
let flairParts = [];

View File

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

View File

@@ -19,6 +19,8 @@ import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {FindOptionsWhere} from "typeorm/find-options/FindOptionsWhere";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
import {RunnableBaseJson, RunnableBaseOptions, StructuredRunnableBase} from "../Common/Infrastructure/Runnable";
import { SubredditResources } from "../Subreddit/SubredditResources";
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
export abstract class Action extends RunnableBase {
name?: string;
@@ -29,6 +31,8 @@ export abstract class Action extends RunnableBase {
managerEmitter: EventEmitter;
// actionEntity: ActionEntity | null = null;
actionPremiseEntity: ActionPremise | null = null;
checkName: string;
subredditName: string;
constructor(options: ActionOptions) {
super(options);
@@ -40,6 +44,7 @@ export abstract class Action extends RunnableBase {
subredditName,
dryRun = false,
emitter,
checkName,
} = options;
this.name = name;
@@ -48,6 +53,8 @@ export abstract class Action extends RunnableBase {
this.client = client;
this.logger = logger.child({labels: [`Action ${this.getActionUniqueName()}`]}, mergeArr);
this.managerEmitter = emitter;
this.checkName = checkName;
this.subredditName = subredditName;
}
abstract getKind(): ActionTypes;
@@ -112,7 +119,7 @@ export abstract class Action extends RunnableBase {
}
}
async handle(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionResultEntity> {
async handle(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionResultEntity> {
const {dryRun: runtimeDryrun} = options;
const dryRun = runtimeDryrun || this.dryRun;
@@ -148,10 +155,11 @@ export abstract class Action extends RunnableBase {
actRes.runReason = runReason;
return actRes;
}
const results = await this.process(item, ruleResults, options);
const results = await this.process(item, ruleResults, actionResults, options);
actRes.success = results.success;
actRes.dryRun = results.dryRun;
actRes.result = results.result;
actRes.data = results.data;
actRes.touchedEntities = results.touchedEntities ?? [];
return actRes;
@@ -166,20 +174,31 @@ export abstract class Action extends RunnableBase {
}
}
abstract process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult>;
abstract process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult>;
getRuntimeAwareDryrun(options: runCheckOptions): boolean {
const {dryRun: runtimeDryrun} = options;
return runtimeDryrun || this.dryRun;
}
async renderContent(template: string | undefined, item: SnoowrapActivity, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[]): Promise<string | undefined> {
if(template === undefined) {
return undefined;
}
return await this.resources.renderContent(template, item, ruleResults, actionResults, {manager: this.subredditName, check: this.checkName});
}
}
export interface ActionOptions extends Omit<ActionConfig, 'authorIs' | 'itemIs'>, RunnableBaseOptions {
//logger: Logger;
subredditName: string;
//resources: SubredditResources;
export interface ActionRuntimeOptions {
checkName: string
subredditName: string
client: ExtendedSnoowrap;
emitter: EventEmitter
emitter: EventEmitter;
resources: SubredditResources;
logger: Logger;
}
export interface ActionOptions extends Omit<ActionConfig, 'authorIs' | 'itemIs'>, RunnableBaseOptions, ActionRuntimeOptions {
}
export interface ActionConfig extends RunnableBaseJson {

View File

@@ -1,4 +1,5 @@
import Snoowrap, {Comment, ConfigOptions, RedditUser, Submission, Subreddit} from "snoowrap";
import Snoowrap, {Comment, ConfigOptions, RedditUser, Submission} from "snoowrap";
import {Subreddit} from "snoowrap/dist/objects"
import {Logger} from "winston";
import dayjs, {Dayjs} from "dayjs";
import {Duration} from "dayjs/plugin/duration";
@@ -27,7 +28,15 @@ import {CommentStream, ModQueueStream, SPoll, SubmissionStream, UnmoderatedStrea
import {BotResourcesManager} from "../Subreddit/SubredditResources";
import LoggedError from "../Utils/LoggedError";
import pEvent from "p-event";
import {SimpleError, isRateLimitError, isRequestError, isScopeError, isStatusError, CMError} from "../Utils/Errors";
import {
SimpleError,
isRateLimitError,
isRequestError,
isScopeError,
isStatusError,
CMError,
ISeriousError, definesSeriousError
} from "../Utils/Errors";
import {ErrorWithCause} from "pony-cause";
import {DataSource, Repository} from "typeorm";
import {Bot as BotEntity} from '../Common/Entities/Bot';
@@ -43,10 +52,17 @@ import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterSha
import {snooLogWrapper} from "../Utils/loggerFactory";
import {InfluxClient} from "../Common/Influx/InfluxClient";
import {Point} from "@influxdata/influxdb-client";
import {BotInstanceFunctions, NormalizedManagerResponse} from "../Web/Common/interfaces";
import {
BotInstanceFunctions, HydratedSubredditInviteData,
NormalizedManagerResponse,
SubredditInviteData,
SubredditInviteDataPersisted, SubredditOnboardingReadiness
} from "../Web/Common/interfaces";
import {AuthorEntity} from "../Common/Entities/AuthorEntity";
import {Guest, GuestEntityData} from "../Common/Entities/Guest/GuestInterfaces";
import {guestEntitiesToAll, guestEntityToApiGuest} from "../Common/Entities/Guest/GuestEntity";
import {SubredditInvite} from "../Common/Entities/SubredditInvite";
import {dayjsDTFormat} from "../Common/defaults";
class Bot implements BotInstanceFunctions {
@@ -61,6 +77,7 @@ class Bot implements BotInstanceFunctions {
excludeSubreddits: string[];
filterCriteriaDefaults?: FilterCriteriaDefaults
subManagers: Manager[] = [];
moderatedSubreddits: Subreddit[] = []
heartbeatInterval: number;
nextHeartbeat: Dayjs = dayjs();
heartBeating: boolean = false;
@@ -105,6 +122,8 @@ class Bot implements BotInstanceFunctions {
runTypeRepo: Repository<RunStateType>;
managerRepo: Repository<ManagerEntity>;
authorRepo: Repository<AuthorEntity>;
subredditInviteRepo: Repository<SubredditInvite>
botRepo: Repository<BotEntity>
botEntity!: BotEntity
getBotName = () => {
@@ -168,6 +187,8 @@ class Bot implements BotInstanceFunctions {
this.runTypeRepo = this.database.getRepository(RunStateType);
this.managerRepo = this.database.getRepository(ManagerEntity);
this.authorRepo = this.database.getRepository(AuthorEntity);
this.subredditInviteRepo = this.database.getRepository(SubredditInvite)
this.botRepo = this.database.getRepository(BotEntity)
this.config = config;
this.dryRun = parseBool(dryRun) === true ? true : undefined;
this.softLimit = softLimit;
@@ -386,7 +407,7 @@ class Bot implements BotInstanceFunctions {
async testClient(initial = true) {
try {
// @ts-ignore
const user = this.client.getMe().fetch();
const user = await this.client.getMe().fetch();
this.logger.info('Test API call successful');
return user;
} catch (err: any) {
@@ -406,18 +427,27 @@ class Bot implements BotInstanceFunctions {
}
}
async getModeratedSubreddits(refresh = false) {
if(this.moderatedSubreddits.length > 0 && !refresh) {
return this.moderatedSubreddits;
}
let subListing = await this.client.getModeratedSubreddits({count: 100});
while (!subListing.isFinished) {
subListing = await subListing.fetchMore({amount: 100});
}
const availSubs = subListing.filter(x => x.display_name !== `u_${this.botUser?.name}`);
this.moderatedSubreddits = availSubs;
return availSubs;
}
async buildManagers(subreddits: string[] = []) {
await this.init();
this.logger.verbose('Syncing subreddits to moderate with managers...');
let availSubs: Subreddit[] = [];
let subListing = await this.client.getModeratedSubreddits({count: 100});
while(!subListing.isFinished) {
subListing = await subListing.fetchMore({amount: 100});
}
availSubs = subListing.filter(x => x.display_name !== `u_${this.botUser?.name}`);
const availSubs = await this.getModeratedSubreddits(true);
this.logger.verbose(`${this.botAccount} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
@@ -499,7 +529,7 @@ class Bot implements BotInstanceFunctions {
for (const sub of subsToRun) {
if(!this.subManagers.some(x => x.subreddit.display_name === sub.display_name)) {
subManagersChanged = true;
this.logger.info(`Manager for ${sub.display_name_prefixed} not found in existing managers. Creating now...`);
this.logger.info(`Manager for ${sub.display_name_prefixed} not found in loaded managers. Loading now...`);
subsToInit.push(sub.display_name);
try {
this.subManagers.push(await this.createManager(sub));
@@ -635,7 +665,7 @@ class Bot implements BotInstanceFunctions {
await manager.parseConfiguration('system', true, {suppressNotification: true, suppressChangeEvent: true});
} catch (err: any) {
if(err.logged !== true) {
const normalizedError = new ErrorWithCause(`Bot could not initialize manager because config was not valid`, {cause: err});
const normalizedError = new ErrorWithCause(`Bot could not initialize manager`, {cause: err});
// @ts-ignore
this.logger.error(normalizedError, {subreddit: manager.subreddit.display_name_prefixed});
} else {
@@ -713,6 +743,9 @@ class Bot implements BotInstanceFunctions {
eventsState: new EventsRunState({invokee, runType}),
managerState: new ManagerRunState({invokee, runType})
}));
this.logger.info(`Created new Manager (${managerEntity.id}) for ${subVal.display_name}`);
} else {
this.logger.info(`Found existing Manager (${managerEntity.id}) for ${subVal.display_name}`);
}
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {
@@ -760,21 +793,50 @@ class Bot implements BotInstanceFunctions {
}
async checkModInvites() {
const subs: string[] = await this.cacheManager.getPendingSubredditInvites();
for (const name of subs) {
try {
// @ts-ignore
await this.client.getSubreddit(name).acceptModeratorInvite();
this.logger.info(`Accepted moderator invite for r/${name}!`);
await this.cacheManager.deletePendingSubredditInvite(name);
} catch (err: any) {
if (err.message.includes('NO_INVITE_FOUND')) {
this.logger.warn(`No pending moderation invite for r/${name} was found`);
} else if (isStatusError(err) && err.statusCode === 403) {
this.logger.error(`Error occurred while checking r/${name} for a pending moderation invite. It is likely that this bot does not have the 'modself' oauth permission. Error: ${err.message}`);
} else {
this.logger.error(`Error occurred while checking r/${name} for a pending moderation invite. Error: ${err.message}`);
this.logger.debug('Checking onboarding invites...');
const expired = this.botEntity.getSubredditInvites().filter(x => x.expiresAt !== undefined && x.expiresAt.isSameOrBefore(dayjs()));
for (const exp of expired) {
this.logger.debug(`Onboarding invite for ${exp.subreddit} expired at ${exp.expiresAt?.format(dayjsDTFormat)}`);
await this.deleteSubredditInvite(exp);
}
for (const subInvite of this.botEntity.getSubredditInvites()) {
if (subInvite.canAutomaticallyAccept()) {
try {
await this.acceptModInvite(subInvite);
await this.deleteSubredditInvite(subInvite);
} catch (err: any) {
if(definesSeriousError(err) && !err.isSerious) {
this.logger.warn(err);
} else {
this.logger.error(err);
}
}
} else {
this.logger.debug(`Cannot try to automatically accept mod invite for ${subInvite.subreddit} because it has additional settings that require moderator approval`);
}
}
}
async acceptModInvite(invite: SubredditInvite) {
const {subreddit: name} = invite;
try {
// @ts-ignore
await this.client.getSubreddit(name).acceptModeratorInvite();
this.logger.info(`Accepted moderator invite for r/${name}!`);
} catch (err: any) {
if (err.message.includes('NO_INVITE_FOUND')) {
throw new SimpleError(`No pending moderation invite for r/${name} was found`, {isSerious: false});
} else if (isStatusError(err) && err.statusCode === 403) {
let msg = `Error occurred while checking r/${name} for a pending moderation invite.`;
if(!this.client.scope.includes('modself')) {
msg = `${msg} This bot must have the 'modself' oauth permission in order to accept invites.`;
} else {
msg = `${msg} If this subreddit is private it is likely no moderation invite exists.`;
}
throw new CMError(msg, {cause: err})
} else {
throw new CMError(`Error occurred while checking r/${name} for a pending moderation invite.`, {cause: err});
}
}
}
@@ -1236,6 +1298,140 @@ class Bot implements BotInstanceFunctions {
return newGuests;
}
async addSubredditInvite(data: HydratedSubredditInviteData){
let sub: Subreddit;
let name: string;
if (data.subreddit instanceof Subreddit) {
sub = data.subreddit;
name = sub.display_name;
} else {
try {
const maybeName = parseRedditEntity(data.subreddit);
name = maybeName.name;
} catch (e: any) {
throw new SimpleError(`Value '${data.subreddit}' is not a valid subreddit name`);
}
try {
const [exists, foundSub] = await this.client.subredditExists(name);
if (!exists) {
throw new SimpleError(`No subreddit with the name ${name} exists`);
}
if (foundSub !== undefined) {
name = foundSub.display_name;
}
} catch (e: any) {
throw e;
}
}
if((await this.subredditInviteRepo.findOneBy({subreddit: name}))) {
throw new CMError(`Invite for ${name} already exists`);
}
const invite = new SubredditInvite({
subreddit: name,
initialConfig: data.initialConfig,
guests: data.guests,
bot: this.botEntity
})
await this.subredditInviteRepo.save(invite);
this.botEntity.addSubredditInvite(invite);
return invite;
}
getSubredditInvites(): SubredditInviteDataPersisted[] {
if(this.botEntity !== undefined) {
return this.botEntity.getSubredditInvites().map(x => x.toSubredditInviteData());
}
this.logger.warn('No bot entity found');
return [];
}
getInvite(id: string): SubredditInvite | undefined {
if(this.botEntity !== undefined) {
return this.botEntity.getSubredditInvites().find(x => x.id === id);
}
this.logger.warn('No bot entity found');
return undefined;
}
getOnboardingReadiness(invite: SubredditInvite): SubredditOnboardingReadiness {
const hasManager = this.subManagers.some(x => x.subreddit.display_name.toLowerCase() === invite.subreddit.toLowerCase());
const isMod = this.moderatedSubreddits.some(x => x.display_name.toLowerCase() === invite.subreddit.toLowerCase());
return {
hasManager,
isMod
};
}
async finishOnboarding(invite: SubredditInvite) {
const readiness = this.getOnboardingReadiness(invite);
if (readiness.hasManager || readiness.isMod) {
this.logger.info(`Bot is already a mod of ${invite.subreddit}. Finishing onboarding early.`);
await this.deleteSubredditInvite(invite);
}
try {
await this.acceptModInvite(invite);
} catch (e: any) {
throw e;
}
try {
// rebuild managers to get new subreddit
await this.buildManagers();
const manager = this.subManagers.find(x => x.subreddit.display_name.toLowerCase() === invite.subreddit.toLowerCase());
if (manager === undefined) {
throw new CMError('Accepted moderator invitation but could not find manager after rebuilding??');
}
const {guests = [], initialConfig} = invite;
// add guests
if (guests.length > 0) {
await this.addGuest(guests, dayjs().add(1, 'day'), manager.subreddit.display_name);
}
// set initial config
if (initialConfig !== undefined) {
let data: string;
try {
const res = await manager.resources.getExternalResource(initialConfig);
data = res.val;
} catch (e: any) {
throw new CMError(`Accepted moderator invitation but error occurred while trying to fetch config from Initial Config value (${initialConfig})`, {cause: e});
}
try {
await manager.writeConfig(data, 'Generated by Initial Config during onboarding')
} catch (e: any) {
throw new CMError(`Accepted moderator invitation but error occurred while trying to set wiki config value from initial config (${initialConfig})`, {cause: e});
}
// it's ok if this fails because we've already done all the onboarding steps. user can still access the dashboard and all settings have been applied (even if they were invalid IE config)
manager.parseConfiguration('system', true).catch((err: any) => {
if(err.logged !== true) {
this.logger.error(err, {subreddit: manager.displayLabel});
}
})
}
} catch(e: any) {
throw e;
} finally {
await this.deleteSubredditInvite(invite);
}
}
async deleteSubredditInvite(val: string | SubredditInvite) {
let invite: SubredditInvite;
if(val instanceof SubredditInvite) {
invite = val;
} else {
const maybeInvite = this.botEntity.getSubredditInvites().find(x => x.subreddit === val);
if(maybeInvite === undefined) {
throw new CMError(`No invite for subreddit ${val} exists for this Bot`);
}
invite = maybeInvite;
}
await this.subredditInviteRepo.delete({id: invite.id});
this.botEntity.removeSubredditInvite(invite);
}
}
export default Bot;

View File

@@ -222,7 +222,14 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
this.actions.push(actionFactory({
...aj,
dryRun: this.dryRun || aj.dryRun
}, this.logger, subredditName, this.resources, this.client, this.emitter));
}, {
logger: this.logger,
subredditName,
resources: this.resources,
client: this.client,
emitter: this.emitter,
checkName: this.name
}));
// @ts-ignore
a.logger = this.logger;
} else {
@@ -564,7 +571,7 @@ export abstract class Check extends RunnableBase implements Omit<ICheck, 'postTr
const dr = dryRun || this.dryRun;
this.logger.debug(`${dr ? 'DRYRUN - ' : ''}Running Actions`);
for (const a of this.actions) {
const res = await a.handle(item, ruleResults, options);
const res = await a.handle(item, ruleResults, runActions, options);
runActions.push(res);
}
this.logger.info(`${dr ? 'DRYRUN - ' : ''}Ran Actions: ${runActions.map(x => x.premise.getFriendlyIdentifier()).join(' | ')}`);

View File

@@ -0,0 +1,34 @@
import {ActivitySourceData, ActivitySourceTypes} from "./Infrastructure/Atomic";
import {strToActivitySourceData} from "../util";
export class ActivitySource {
type: ActivitySourceTypes
identifier?: string
constructor(data: string | ActivitySourceData) {
if (typeof data === 'string') {
const {type, identifier} = strToActivitySourceData(data);
this.type = type;
this.identifier = identifier;
} else {
this.type = data.type;
this.identifier = data.identifier;
}
}
matches(desired: ActivitySource): boolean {
if(desired.type !== this.type) {
return false;
}
// if this source does not have an identifier (we have already matched type) then it is broad enough to match
if(this.identifier === undefined) {
return true;
}
// at this point we know this source has an identifier but desired DOES NOT so this source is more restrictive and does not match
if(desired.identifier === undefined) {
return false;
}
// otherwise sources match if identifiers are the same
return this.identifier.toLowerCase() === desired.identifier.toLowerCase();
}
}

View File

@@ -56,6 +56,11 @@ export class ActionResultEntity extends TimeAwareRandomBaseEntity {
@JoinColumn({name: 'premiseId'})
premise!: ActionPremise;
/**
* Ephemeral -- only added during actual run time and used for action templating. Is not available after loading from DB.
* */
data?: any;
touchedEntities: (Submission | Comment | RedditUser | string)[] = []
set itemIs(data: ActivityStateFilterResult | IFilterResult<TypedActivityState> | undefined) {

View File

@@ -65,4 +65,26 @@ export class Bot extends RandomIdBaseEntity implements HasGuests {
this.guests = []
return [];
}
getSubredditInvites(): SubredditInvite[] {
if(this.subredditInvites === undefined) {
return [];
}
return this.subredditInvites;
}
addSubredditInvite(invite: SubredditInvite) {
if(this.subredditInvites === undefined) {
this.subredditInvites = [];
}
this.subredditInvites.push(invite);
}
removeSubredditInvite(invite: SubredditInvite) {
if(this.subredditInvites === undefined) {
return;
}
const index = this.subredditInvites.findIndex(x => x.id === invite.id);
this.subredditInvites.splice(index, 1);
}
}

View File

@@ -21,7 +21,7 @@ import Submission from "snoowrap/dist/objects/Submission";
import Comment from "snoowrap/dist/objects/Comment";
import {ColumnDurationTransformer} from "./Transformers";
import { RedditUser } from "snoowrap/dist/objects";
import {ActivitySourceTypes, DurationVal, NonDispatchActivitySource, onExistingFoundBehavior} from "../Infrastructure/Atomic";
import {ActivitySourceTypes, DurationVal, NonDispatchActivitySourceValue, onExistingFoundBehavior} from "../Infrastructure/Atomic";
@Entity({name: 'DispatchedAction'})
export class DispatchedEntity extends TimeAwareRandomBaseEntity {
@@ -53,7 +53,7 @@ export class DispatchedEntity extends TimeAwareRandomBaseEntity {
identifier?: string
@Column("varchar", {nullable: true, length: 200})
cancelIfQueued?: boolean | NonDispatchActivitySource | NonDispatchActivitySource[]
cancelIfQueued?: boolean | NonDispatchActivitySourceValue | NonDispatchActivitySourceValue[]
@Column({nullable: true})
onExistingFound?: onExistingFoundBehavior
@@ -127,7 +127,7 @@ export class DispatchedEntity extends TimeAwareRandomBaseEntity {
} else if (cVal === 'false') {
this.cancelIfQueued = false;
} else if (cVal.includes('[')) {
this.cancelIfQueued = JSON.parse(cVal) as NonDispatchActivitySource[];
this.cancelIfQueued = JSON.parse(cVal) as NonDispatchActivitySourceValue[];
}
}
if(this.goto === null) {

View File

@@ -43,7 +43,7 @@ export class ManagerEntity extends RandomIdBaseEntity implements RunningStateEnt
@Column("varchar", {length: 200})
name!: string;
@ManyToOne(type => Bot, sub => sub.managers, {cascade: ['insert'], eager: true})
@ManyToOne(type => Bot, sub => sub.managers, {eager: true})
bot!: Bot;
@ManyToOne(type => Subreddit, sub => sub.activities, {cascade: ['insert'], eager: true})

View File

@@ -1,5 +1,5 @@
import {Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from "typeorm";
import {InviteData, SubredditInviteData} from "../../Web/Common/interfaces";
import {AfterLoad, Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from "typeorm";
import {InviteData, SubredditInviteData, SubredditInviteDataPersisted} from "../../Web/Common/interfaces";
import dayjs, {Dayjs} from "dayjs";
import {TimeAwareRandomBaseEntity} from "./Base/TimeAwareRandomBaseEntity";
import {AuthorEntity} from "./AuthorEntity";
@@ -8,6 +8,7 @@ import {Bot} from "./Bot";
@Entity()
export class SubredditInvite extends TimeAwareRandomBaseEntity implements SubredditInviteData {
@PrimaryColumn("varchar", {length: 255})
subreddit!: string;
@Column("simple-json", {nullable: true})
@@ -16,6 +17,9 @@ export class SubredditInvite extends TimeAwareRandomBaseEntity implements Subred
@Column("text")
initialConfig?: string
@PrimaryColumn("varchar", {length: 200})
messageId?: string
@ManyToOne(type => Bot, bot => bot.subredditInvites, {nullable: false, orphanedRowAction: 'delete'})
@JoinColumn({name: 'botId', referencedColumnName: 'id'})
bot!: Bot;
@@ -38,12 +42,13 @@ export class SubredditInvite extends TimeAwareRandomBaseEntity implements Subred
}
}
constructor(data?: SubredditInviteData & { expiresIn?: number }) {
constructor(data?: SubredditInviteData & { expiresIn?: number, bot: Bot }) {
super();
if (data !== undefined) {
this.subreddit = data.subreddit;
this.initialConfig = data.initialConfig;
this.guests = data.guests;
this.initialConfig = data.initialConfig === null ? undefined : data.initialConfig;
this.guests = data.guests === null || data.guests === undefined ? [] : data.guests;
this.bot = data.bot;
if (data.expiresIn !== undefined && data.expiresIn !== 0) {
@@ -51,4 +56,44 @@ export class SubredditInvite extends TimeAwareRandomBaseEntity implements Subred
}
}
}
toSubredditInviteData(): SubredditInviteDataPersisted {
return {
id: this.id,
subreddit: this.subreddit,
initialConfig: this.getInitialConfig(),
guests: this.getGuests(),
expiresAt: this.expiresAt !== undefined ? this.expiresAt.unix() : undefined,
}
}
getGuests(): string[] {
if(this.guests === null || this.guests === undefined) {
return [];
}
return this.guests;
}
getInitialConfig(): string | undefined {
if(this.initialConfig === null) {
return undefined;
}
return this.initialConfig;
}
canAutomaticallyAccept() {
return this.getGuests().length === 0 && this.getInitialConfig() === undefined;
// TODO setup inbox checking to look for reply to messageId (eventually!)
}
@AfterLoad()
fixNullable() {
if(this.guests === null) {
this.guests = undefined;
}
if(this.initialConfig === null) {
this.initialConfig = undefined;
}
}
}

View File

@@ -1,16 +1,40 @@
import {InfluxConfig} from "./interfaces";
import {InfluxDB, Point, WriteApi, setLogger} from "@influxdata/influxdb-client";
import {InfluxDB, Point, WriteApi, setLogger, DEFAULT_WriteOptions, ClientOptions, DEFAULT_RetryDelayStrategyOptions, Logger as InfluxLogger} from "@influxdata/influxdb-client";
import {HealthAPI} from "@influxdata/influxdb-client-apis";
import dayjs, {Dayjs} from "dayjs";
import {Logger} from "winston";
import {mergeArr} from "../../util";
import {CMError} from "../../Utils/Errors";
import {Agent} from 'http';
import {WriteOptions} from "@influxdata/influxdb-client/dist";
export interface InfluxClientConfig extends InfluxConfig {
client?: InfluxDB
ready?: boolean
}
/**
* Suppress non-error write failures
*
* These have not yet hit the max retry. On max retry failure Influx logs as ERROR.
* The non-error failures are super noisy in the log so suppress them UNLESS debug is turned on
*
* https://github.com/influxdata/influxdb-client-js/blob/master/packages/core/src/impl/WriteApiImpl.ts#L221
* */
const extendLogger = (logger: Logger, suppressWriteWarnings = true): InfluxLogger => {
return {
...logger,
error: (message: string, err?: any) => logger.error(message, err),
warn: (message: string, err?: any) => {
if(suppressWriteWarnings && !message.includes('Write to InfluxDB failed (attempt')) {
logger.warn(message, err);
} else if(!suppressWriteWarnings) {
logger.warn(message, err);
}
}
}
}
export class InfluxClient {
config: InfluxConfig;
client: InfluxDB;
@@ -34,13 +58,14 @@ export class InfluxClient {
this.config = rest;
this.ready = ready;
if(client !== undefined) {
if (client !== undefined) {
this.client = client;
} else {
this.client = InfluxClient.createClient(this.config);
setLogger(this.logger);
this.client = InfluxClient.createClient(this.config);
setLogger(extendLogger(this.logger, !(rest.debug ?? false)));
}
this.write = this.client.getWriteApi(config.credentials.org, config.credentials.bucket, 'ms');
this.write = this.client.getWriteApi(config.credentials.org, config.credentials.bucket, 'ms', InfluxClient.createWriteOptions(this.config, this.logger));
this.tags = tags;
this.write.useDefaultTags(tags);
this.health = new HealthAPI(this.client);
@@ -96,13 +121,62 @@ export class InfluxClient {
}
static createClient(config: InfluxConfig): InfluxDB {
return new InfluxDB({
url: config.credentials.url,
token: config.credentials.token,
const {
credentials,
useKeepAliveAgent = true,
} = config;
const clientOptions: ClientOptions = {
url: credentials.url,
token: credentials.token,
writeOptions: InfluxClient.createWriteOptions(config),
}
if (useKeepAliveAgent) {
// reusing connection
// https://github.com/influxdata/influxdb-client-js/issues/393#issuecomment-985272866
const agent = new Agent({
keepAlive: true,
keepAliveMsecs: 20 * 1000, // 20 seconds keep alive
})
process.on('exit', () => agent.destroy())
clientOptions.transportOptions = {agent};
}
return new InfluxDB(clientOptions);
}
static createWriteOptions(config: InfluxConfig, logger?: Logger): Partial<WriteOptions> {
const {
writeOptions: {
defaultTags: config.defaultTags
defaultTags: userDefinedDefaultTags = {},
...restUserWriteOptions
} = {
batchSize: 500,
maxRetries: 5,
// 30 seconds
flushInterval: 30000
},
defaultTags: legacyDefaultTags = {},
debug = false,
} = config;
const allUserDefinedTags = {...legacyDefaultTags, ...userDefinedDefaultTags};
const writeOptions: Partial<WriteOptions> = {
...DEFAULT_WriteOptions,
...restUserWriteOptions,
defaultTags: allUserDefinedTags
}
if (debug && logger !== undefined) {
writeOptions.writeSuccess = (lines: Array<string>) => {
logger.debug(`Flushed ${lines.length} lines to server`);
};
writeOptions.writeRetrySkipped = (entry: { lines: Array<string>; expires: number }) => {
logger.warn(`Skipped flushing ${entry.lines.length} lines due to full buffer`);
}
});
}
return writeOptions;
}
childClient(logger: Logger, tags: Record<string, string> = {}) {

View File

@@ -1,8 +1,11 @@
import {InfluxDB, WriteApi} from "@influxdata/influxdb-client/dist";
import {InfluxDB, WriteApi, WriteOptions} from "@influxdata/influxdb-client/dist";
export interface InfluxConfig {
credentials: InfluxCredentials
defaultTags?: Record<string, string>
writeOptions?: WriteOptions
useKeepAliveAgent?: boolean
debug?: boolean
}
export interface InfluxCredentials {

View File

@@ -1,3 +1,5 @@
import {ActivityType} from "./Reddit";
/**
* A duration and how to compare it against a value
*
@@ -15,6 +17,22 @@
export type DurationComparor = string;
/**
* A relative datetime description
*
* May be either:
*
* * day of the week (monday, tuesday, etc...)
* * cron expression IE `* * 15 *`
*
* See https://crontab.guru/ for generating expressions
*
* https://regexr.com/6u3cc
*
* @pattern ((?:(?:(?:(?:\d+,)+\d+|(?:\d+(?:\/|-|#)\d+)|\d+L?|\*(?:\/\d+)?|L(?:-\d+)?|\?|[A-Z]{3}(?:-[A-Z]{3})?) ?){5,7})$)|(mon|tues|wed|thurs|fri|sat|sun){1}
* */
export type RelativeDateTimeMatch = string;
/**
* A string containing a comparison operator and a value to compare against
*
@@ -23,7 +41,7 @@ export type DurationComparor = string;
* * EX `> 100` => greater than 100
* * EX `<= 75%` => less than or equal to 75%
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* @pattern ^\s*(>|>=|<|<=)\s*((?:\d+)(?:(?:(?:.|,)\d+)+)?)\s*(%?)(.*)$
* */
export type CompareValueOrPercent = string;
export type StringOperator = '>' | '>=' | '<' | '<=';
@@ -150,9 +168,16 @@ export type onExistingFoundBehavior = 'replace' | 'skip' | 'ignore';
export type ActionTarget = 'self' | 'parent';
export type ArbitraryActionTarget = ActionTarget | string;
export type InclusiveActionTarget = ActionTarget | 'any';
export type DispatchSource = 'dispatch' | `dispatch:${string}`;
export type NonDispatchActivitySource = 'poll' | `poll:${PollOn}` | 'user' | `user:${string}`;
export type ActivitySourceTypes = 'poll' | 'dispatch' | 'user'; // TODO
export const SOURCE_POLL = 'poll';
export type SourcePollStr = 'poll';
export const SOURCE_DISPATCH = 'dispatch';
export type SourceDispatchStr = 'dispatch';
export const SOURCE_USER = 'user';
export type SourceUserStr = 'user';
export type DispatchSourceValue = SourceDispatchStr | `dispatch:${string}`;
export type NonDispatchActivitySourceValue = SourcePollStr | `poll:${PollOn}` | SourceUserStr | `user:${string}`;
export type ActivitySourceTypes = SourcePollStr | SourceDispatchStr | SourceUserStr; // TODO
// https://github.com/YousefED/typescript-json-schema/issues/426
// https://github.com/YousefED/typescript-json-schema/issues/425
// @pattern ^(((poll|dispatch)(:\w+)?)|user)$
@@ -170,7 +195,12 @@ export type ActivitySourceTypes = 'poll' | 'dispatch' | 'user'; // TODO
*
*
* */
export type ActivitySource = NonDispatchActivitySource | DispatchSource;
export type ActivitySourceValue = NonDispatchActivitySourceValue | DispatchSourceValue;
export interface ActivitySourceData {
type: ActivitySourceTypes
identifier?: string
}
export type ConfigFormat = 'json' | 'yaml';
export type ActionTypes =
@@ -284,3 +314,84 @@ export interface ImageHashCacheData {
original?: string
flipped?: string
}
// https://www.reddit.com/message/compose?to=/r/mealtimevideos&message=https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot
export interface BaseTemplateData {
botLink: string
modmailLink?: string
manager?: string
check?: string
//[key: string]: any
}
export interface ActivityTemplateData {
kind: ActivityType
author: string
votes: number
age: string
permalink: string
id: string
subreddit: string
title: string
shortTitle: string
}
export interface ModdedActivityTemplateData {
reports: number
modReports: number
userReports: number
}
export interface SubmissionTemplateData extends ActivityTemplateData, Partial<ModdedActivityTemplateData> {
nsfw: boolean
spoiler: boolean
op: boolean
upvoteRatio: string
url: string
}
export interface CommentTemplateData extends ActivityTemplateData, Partial<ModdedActivityTemplateData> {
op: boolean
}
export interface SubredditTemplateData {
subredditBreakdownFormatted: string
subredditBreakdown?: {
totalFormatted: string
submissionFormatted: string
commentFormatted: string
}
}
export interface RuleResultTemplateData {
kind: string
triggered: boolean
result: string
[key: string]: any
}
export interface ActionResultTemplateData {
kind: string
success: boolean
result: string
[key: string]: any
}
export interface ActionResultsTemplateData {
actionSummary: string
actions: {
[key: string]: ActionResultTemplateData
}
}
export interface RuleResultsTemplateData {
ruleSummary: string
rules: {
[key: string]: RuleResultTemplateData
}
}
export interface GenericContentTemplateData extends BaseTemplateData, Partial<RuleResultsTemplateData>, Partial<ActionResultsTemplateData> {
item?: (SubmissionTemplateData | CommentTemplateData)
}

View File

@@ -35,8 +35,8 @@ export const asGenericComparison = (val: any): val is GenericComparison => {
return typeof val === 'object' && 'value' in val;
}
export const GENERIC_VALUE_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>-?\d?\.?\d+)(?<extra>\s+.*)*$/
export const GENERIC_VALUE_COMPARISON_URL = 'https://regexr.com/60dq4';
export const GENERIC_VALUE_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>-?(?:\d+)(?:(?:(?:.|,)\d+)+)?)(?<extra>\s+.*)*$/
export const GENERIC_VALUE_COMPARISON_URL = 'https://regexr.com/6vama';
export const parseGenericValueComparison = (val: string, options?: {
requireDuration?: boolean,
reg?: RegExp
@@ -107,8 +107,8 @@ export const parseGenericValueComparison = (val: string, options?: {
durationText,
}
}
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%)?(?<extra>.*)$/
const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/60a16';
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>(?:\d+)(?:(?:(?:.|,)\d+)+)?)\s*(?<percent>%)?(?<extra>.*)$/
const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/6valr';
export const parseGenericValueOrPercentComparison = (val: string, options?: {requireDuration: boolean}): GenericComparison => {
return parseGenericValueComparison(val, {...(options ?? {}), reg: GENERIC_VALUE_PERCENT_COMPARISON});
}

View File

@@ -4,11 +4,12 @@ import {
DurationComparor,
ModeratorNameCriteria,
ModeratorNames, ModActionType,
ModUserNoteLabel
ModUserNoteLabel, RelativeDateTimeMatch
} from "../Atomic";
import {ActivityType} from "../Reddit";
import {ActivityType, MaybeActivityType} from "../Reddit";
import {GenericComparison, parseGenericValueComparison} from "../Comparisons";
import {parseStringToRegexOrLiteralSearch} from "../../../util";
import { Submission, Comment } from "snoowrap";
/**
* Different attributes a `Subreddit` can be in. Only include a property if you want to check it.
@@ -118,17 +119,49 @@ export interface UserNoteCriteria extends UserSubredditHistoryCriteria {
* @examples ["spamwarn"]
* */
type: string;
/**
* The content of the Note to search For.
*
* * Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.
* * Can also be Regular Expression if wrapped in forward slashes IE '\/test.*\/i'
* */
note?: string | string[]
/*
* Does this note link to the currently processing Activity?
* */
referencesCurrentActivity?: boolean
}
export interface FullUserNoteCriteria extends Omit<UserNoteCriteria, 'note'> {
note?: RegExp[]
}
export const toFullUserNoteCriteria = (val: UserNoteCriteria): FullUserNoteCriteria => {
const {note} = val;
let notesVal = undefined;
if (note !== undefined) {
const notesArr = Array.isArray(note) ? note : [note];
notesVal = notesArr.map(x => parseStringToRegexOrLiteralSearch(x));
}
return {
...val,
note: notesVal
}
}
export interface ModActionCriteria extends UserSubredditHistoryCriteria {
type?: ModActionType | ModActionType[]
activityType?: ActivityType | ActivityType[]
activityType?: MaybeActivityType | MaybeActivityType[]
referencesCurrentActivity?: boolean
}
export interface FullModActionCriteria extends Omit<ModActionCriteria, 'count'> {
type?: ModActionType[]
count?: GenericComparison
activityType?: ActivityType[]
/*
* Does this action/note link to the currently processing Activity?
* */
activityType?: MaybeActivityType[]
}
export interface ModNoteCriteria extends ModActionCriteria {
@@ -138,6 +171,12 @@ export interface ModNoteCriteria extends ModActionCriteria {
export interface FullModNoteCriteria extends FullModActionCriteria, Omit<ModNoteCriteria, 'note' | 'count' | 'type' | 'activityType'> {
noteType?: ModUserNoteLabel[]
/**
* The content of the Note to search For.
*
* * Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.
* * Can also be Regular Expression if wrapped in forward slashes IE '\/test.*\/i'
* */
note?: RegExp[]
}
@@ -167,6 +206,7 @@ export const toFullModNoteCriteria = (val: ModNoteCriteria): FullModNoteCriteria
break;
case 'activityType':
case 'noteType':
case 'referencesCurrentActivity':
acc[k] = rawVal;
break;
case 'note':
@@ -219,6 +259,7 @@ export const toFullModLogCriteria = (val: ModLogCriteria): FullModLogCriteria =>
break;
case 'activityType':
case 'type':
case 'referencesCurrentActivity':
acc[k as keyof FullModLogCriteria] = rawVal;
break;
case 'action':
@@ -245,10 +286,11 @@ export const authorCriteriaProperties = ['name', 'flairCssClass', 'flairText', '
* */
export interface AuthorCriteria {
/**
* A list of reddit usernames (case-insensitive) to match against. Do not include the "u/" prefix
* A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the "u/" prefix
*
*
* EX to match against /u/FoxxMD and /u/AnotherUser use ["FoxxMD","AnotherUser"]
* @examples ["FoxxMD","AnotherUser"]
* @examples ["FoxxMD","AnotherUser", "/.*Foxx.\/*i"]
* */
name?: string[],
/**
@@ -440,6 +482,21 @@ export interface ActivityState {
* */
reports?: string
age?: DurationComparor
/**
* A relative datetime description to match the date the Activity was created
*
* May be either:
*
* * day of the week (monday, tuesday, etc...)
* * cron expression IE `* * 15 *`
*
* See https://crontab.guru/ for generating expressions
*
* https://regexr.com/6u3cc
*
* */
createdOn?: RelativeDateTimeMatch | RelativeDateTimeMatch[]
/**
* Test whether the activity is present in dispatched/delayed activities
*
@@ -469,6 +526,33 @@ export interface ActivityState {
*
* */
source?: string | string[]
/**
* * If `true` then passes if ANY flair
* * If `false` then passes if NO flair
* * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
* */
authorFlairText?: boolean | string | string[]
/**
* * If `true` then passes if ANY flair
* * If `false` then passes if NO flair
* * If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
* */
authorFlairTemplateId?: boolean | string | string[]
/**
* * If `true` then passes if ANY class
* * If `false` then passes if NO class
* * If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
* */
authorFlairCssClass?: boolean | string | string[]
/**
* * If `true` then passes if ANY color
* * If `false` then passes if NO color
* * If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes.
* */
authorFlairBackgroundColor?: boolean | string | string[]
}
/**
@@ -491,13 +575,22 @@ export interface SubmissionState extends ActivityState {
/**
* * If `true` then passes if flair has ANY text
* * If `false` then passes if flair has NO text
* * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
* */
link_flair_text?: boolean | string | string[]
/**
* * If `true` then passes if flair has ANY css
* * If `false` then passes if flair has NO css
* * If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes.
* */
link_flair_css_class?: boolean | string | string[]
/**
* * If `true` then passes if ANY color
* * If `false` then passes if NO color
* * If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes.
* */
link_flair_background_color?: boolean | string | string[]
/**
* * If `true` then passes if there is ANY flair template id
* * If `false` then passes if there is NO flair template id
@@ -521,6 +614,16 @@ export interface SubmissionState extends ActivityState {
upvoteRatio?: number | CompareValue
}
export const cmToSnoowrapActivityMap: Record<string, keyof (Submission & Comment)> = {
authorFlairText: 'author_flair_text',
flairText: 'author_flair_text',
authorFlairTemplateId: 'author_flair_template_id',
authorFlairCssClass: 'author_flair_css_class',
authorFlairBackgroundColor: 'author_flair_background_color',
flairTemplate: 'link_flair_template_id',
flairCssClass: 'author_flair_css_class',
}
export const cmActivityProperties = ['submissionState', 'score', 'reports', 'removed', 'deleted', 'filtered', 'age', 'title'];
/**

View File

@@ -1,6 +1,7 @@
import {Comment, Submission} from "snoowrap/dist/objects";
export type ActivityType = 'submission' | 'comment';
export type MaybeActivityType = ActivityType | false;
export type FullNameTypes = ActivityType | 'user' | 'subreddit' | 'message';
export interface RedditThing {
@@ -88,3 +89,18 @@ export interface SubredditRemovalReason {
id: string,
title: string
}
export interface SubredditActivityAbsoluteBreakdown {
count: number
name: string
}
export interface SubredditActivityBreakdown extends SubredditActivityAbsoluteBreakdown {
percent: number
}
export interface SubredditActivityBreakdownByType {
total: SubredditActivityBreakdown[]
submission: SubredditActivityBreakdown[]
comment: SubredditActivityBreakdown[]
}

View File

@@ -0,0 +1,22 @@
import {MigrationInterface, QueryRunner, Table, TableColumn} from "typeorm"
export class subredditInvite1663001719622 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
const table = await queryRunner.getTable('SubredditInvite') as Table;
await queryRunner.addColumns(table, [
new TableColumn( {
name: 'messageId',
type: 'varchar',
length: '200',
isUnique: true,
isNullable: true
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm"
import {RuleType} from "../../../Entities/RuleType";
export class mhs1663609045418 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.manager.getRepository(RuleType).save([
new RuleType('mhs'),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}

View File

@@ -2,6 +2,9 @@ import {HistoricalStatsDisplay} from "./interfaces";
import path from "path";
import {FilterCriteriaDefaults} from "./Infrastructure/Filters/FilterShapes";
export const dayjsDTFormat = 'YYYY-MM-DD HH:mm:ssZ';
export const dayjsTimeFormat = 'HH:mm:ss z';
export const cacheOptDefaults = {ttl: 60, max: 500, checkPeriod: 600};
export const cacheTTLDefaults = {
authorTTL: 60,
@@ -42,4 +45,4 @@ export const filterCriteriaDefault: FilterCriteriaDefaults = {
export const defaultDataDir = path.resolve(__dirname, '../..');
export const defaultConfigFilenames = ['config.json', 'config.yaml'];
export const VERSION = '0.12.0';
export const VERSION = '0.13.2';

View File

@@ -21,7 +21,7 @@ import {
DurationVal,
EventRetentionPolicyRange,
JoinOperands,
NonDispatchActivitySource,
NonDispatchActivitySourceValue,
NotificationEventType,
NotificationProvider,
onExistingFoundBehavior,
@@ -1585,6 +1585,9 @@ export interface ThirdPartyCredentialsJsonConfig {
youtube?: {
apiKey: string
}
mhs?: {
apiKey: string
}
[key: string]: any
}
@@ -1730,6 +1733,7 @@ export interface ActionProcessResult {
dryRun: boolean,
result?: string
touchedEntities?: (Submission | Comment | RedditUser | string)[]
data?: any
}
export interface EventActivity {
@@ -1963,7 +1967,7 @@ export type RequiredItemCrit = Required<(CommentState & SubmissionState)>;
export interface ActivityDispatchConfig {
identifier?: string
cancelIfQueued?: boolean | NonDispatchActivitySource | NonDispatchActivitySource[]
cancelIfQueued?: boolean | NonDispatchActivitySourceValue | NonDispatchActivitySourceValue[]
goto?: string
onExistingFound?: onExistingFoundBehavior
tardyTolerant?: boolean | DurationVal

View File

@@ -19,10 +19,11 @@ import {DispatchActionJson} from "../Action/DispatchAction";
import {CancelDispatchActionJson} from "../Action/CancelDispatchAction";
import {ContributorActionJson} from "../Action/ContributorAction";
import {SentimentRuleJSONConfig} from "../Rule/SentimentRule";
import {MHSRuleJSONConfig} from "../Rule/MHSRule";
import {ModNoteActionJson} from "../Action/ModNoteAction";
import {IncludesData} from "./Infrastructure/Includes";
import { SubmissionActionJson } from "../Action/SubmissionAction";
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig | MHSRuleJSONConfig
export type ActionJson = CommentActionJson | SubmissionActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | ModNoteActionJson | string | IncludesData;

View File

@@ -418,6 +418,7 @@ export class ConfigBuilder {
}
structuredRuns.push({
...r,
filterCriteriaDefaults: configFilterDefaultsFromRun,
checks: structuredChecks,
authorIs: derivedRunAuthorIs,
itemIs: derivedRunItemIs
@@ -642,7 +643,7 @@ const getNamedOrReturn = <T>(namedFilters: Map<string, NamedCriteria<T>>, filter
if(!namedFilters.has(x.toLocaleLowerCase())) {
throw new Error(`No named ${filterName} criteria with the name "${x}"`);
}
return namedFilters.get(x) as NamedCriteria<T>;
return namedFilters.get(x.toLocaleLowerCase()) as NamedCriteria<T>;
}
if(asNamedCriteria(x)) {
return x;

View File

@@ -7,9 +7,10 @@ import {Rule, RuleJSONConfig, RuleOptions} from "./index";
import Submission from "snoowrap/dist/objects/Submission";
import dayjs from "dayjs";
import {
asComment,
asSubmission,
FAIL,
formatNumber, getActivitySubredditName, historyFilterConfigToOptions, isSubmission,
formatNumber, getActivitySubredditName, historyFilterConfigToOptions, isComment, isSubmission,
parseSubredditName,
PASS,
percentFromString, removeUndefinedKeys, toStrongSubredditState, windowConfigToWindowCriteria
@@ -20,6 +21,7 @@ import {CompareValueOrPercent} from "../Common/Infrastructure/Atomic";
import {ActivityWindowConfig, ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
import {ErrorWithCause} from "pony-cause";
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils";
export interface CommentThresholdCriteria extends ThresholdCriteria {
/**
@@ -79,6 +81,23 @@ export interface HistoryCriteria {
window: ActivityWindowConfig
ratio?: {
window: ActivityWindowConfig
/**
* A string containing a comparison operator and a value to compare number of parent criteria activities against number of "ratio" activities
*
* This comparison is always done as (number of parent criteria activities) / (number of ratio activities)
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities
* * EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities
*
* @pattern ^\s*(>|>=|<|<=)\s*((?:\d+)(?:(?:(?:.|,)\d+)+)?)\s*(%?)(.*)$
* */
threshold: CompareValueOrPercent
}
/**
* The minimum number of **filtered** activities that must exist from the `window` results for this criteria to run
* @default 5
@@ -168,7 +187,7 @@ export class HistoryRule extends Rule {
for (const criteria of this.criteria) {
const {comment, window, submission, total, minActivityCount = 5} = criteria;
const {comment, window, submission, total, ratio, minActivityCount = 5} = criteria;
const {pre: activities, post: filteredActivities} = await this.resources.getAuthorActivitiesWithFilter(item.author, window);
@@ -206,10 +225,11 @@ export class HistoryRule extends Rule {
fOpTotal = filteredCounts.opTotal;
}
let asOp = false;
let commentTrigger = undefined;
if(comment !== undefined) {
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(comment);
const asOp = extra.toLowerCase().includes('op');
asOp = extra.toLowerCase().includes('op');
if(isPercent) {
const per = value / 100;
if(asOp) {
@@ -248,6 +268,24 @@ export class HistoryRule extends Rule {
}
}
let foundRatio = undefined;
let ratioTrigger = undefined;
if(ratio !== undefined) {
const { window: ratioWindow, threshold: ratioThreshold } = ratio;
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(ratioThreshold);
const ratioWindowConfig = windowConfigToWindowCriteria(ratioWindow);
const {post: ratioActivities} = await this.resources.getAuthorActivitiesWithFilter(item.author, ratioWindowConfig);
const ratioVal = filteredActivities.length / ratioActivities.length;
foundRatio = formatNumber(ratioVal);
if(isPercent) {
const per = value / 100;
ratioTrigger = comparisonTextOp(ratioVal, operator, per);
} else {
ratioTrigger = comparisonTextOp(ratioVal, operator, value);
}
}
const firstActivity = activities[0];
const lastActivity = activities[activities.length - 1];
@@ -260,11 +298,14 @@ export class HistoryRule extends Rule {
submissionTotal: fSubmissionTotal,
commentTotal: fCommentTotal,
opTotal: fOpTotal,
foundRatio,
filteredTotal: filteredActivities.length,
submissionTrigger,
commentTrigger,
totalTrigger,
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true)
ratioTrigger,
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true) && (ratioTrigger === undefined || ratioTrigger === true),
subredditBreakdown: getSubredditBreakdownByActivityType(!asOp ? filteredActivities : filteredActivities.filter(x => asSubmission(x) || x.is_submitter))
});
}
@@ -296,6 +337,12 @@ export class HistoryRule extends Rule {
this.logger.verbose(`${PASS} ${resultData.result}`);
return Promise.resolve([true, this.getResult(true, resultData)]);
} else {
// log failures for easier debugging
for(const res of criteriaResults) {
const resultData = this.generateResultDataFromCriteria(res);
this.logger.verbose(`${FAIL} ${resultData.result}`);
}
}
return Promise.resolve([false, this.getResult(false, {result: failCriteriaResult})]);
@@ -308,11 +355,13 @@ export class HistoryRule extends Rule {
submissionTotal,
commentTotal,
filteredTotal,
foundRatio,
opTotal,
criteria: {
comment,
submission,
total,
ratio,
window,
},
criteria,
@@ -320,6 +369,8 @@ export class HistoryRule extends Rule {
submissionTrigger,
commentTrigger,
totalTrigger,
ratioTrigger,
subredditBreakdown,
} = results;
const data: any = {
@@ -327,6 +378,7 @@ export class HistoryRule extends Rule {
submissionTotal,
commentTotal,
filteredTotal,
foundRatio,
opTotal,
commentPercent: formatNumber((commentTotal/activityTotal)*100),
submissionPercent: formatNumber((submissionTotal/activityTotal)*100),
@@ -338,12 +390,15 @@ export class HistoryRule extends Rule {
submissionTrigger,
commentTrigger,
totalTrigger,
ratioTrigger,
subredditBreakdown
};
let thresholdSummary = [];
let totalSummary;
let submissionSummary;
let commentSummary;
let ratioSummary;
if(total !== undefined) {
const {operator, value, isPercent, displayText} = parseGenericValueOrPercentComparison(total);
const suffix = !isPercent ? 'Items' : `(${formatNumber((filteredTotal/activityTotal)*100)}%) of ${activityTotal} Total`;
@@ -368,6 +423,13 @@ export class HistoryRule extends Rule {
data.commentSummary = commentSummary;
thresholdSummary.push(commentSummary);
}
if(ratio !== undefined) {
const {threshold} = ratio;
const {operator, value, isPercent, displayText, extra = ''} = parseGenericValueOrPercentComparison(threshold);
ratioSummary = `${includePassFailSymbols ? `${submissionTrigger ? PASS : FAIL} ` : ''}Activity Ratio of (${foundRatio}) ${ratioTrigger ? 'passed' : 'did not pass'} test of ${displayText}`;
data.ratioSummary = ratioSummary;
thresholdSummary.push(ratioSummary);
}
data.thresholdSummary = thresholdSummary.join(' and ');

420
src/Rule/MHSRule.ts Normal file
View File

@@ -0,0 +1,420 @@
import {Rule, RuleJSONConfig, RuleOptions} from "./index";
import {Comment} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {
asComment, boolToString,
formatNumber,
triggeredIndicator, windowConfigToWindowCriteria
} from "../util";
import got, {HTTPError} from 'got';
import dayjs from 'dayjs';
import {map as mapAsync} from 'async';
import {
comparisonTextOp,
GenericComparison,
parseGenericValueOrPercentComparison,
} from "../Common/Infrastructure/Comparisons";
import {ActivityWindowConfig, ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
import {RuleResult} from "../Common/interfaces";
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
import {CMError} from "../Utils/Errors";
import objectHash from "object-hash";
const formatConfidence = (val: number) => formatNumber(val * 100, {
suffix: '%',
toFixed: 2
});
export class MHSRule extends Rule {
criteria: MHSCriteria
historical?: HistoricalMHS;
ogConfig: MHSConfig
constructor(options: MHSRuleOptions) {
super(options);
if (this.resources.thirdPartyCredentials.mhs?.apiKey === undefined) {
throw new CMError(`MHS (moderatehatespeech.com) API Key has not been specified. It must be present in the bot config or top-level subreddit 'credentials' property.`);
}
const {
criteria,
historical,
} = options;
this.ogConfig = {
criteria,
historical
};
const {
flagged = true,
confidence = '>= 90',
testOn = ['body']
} = criteria || {};
this.criteria = {
flagged,
confidence: confidence !== undefined ? parseGenericValueOrPercentComparison(confidence) : undefined,
testOn,
}
if (options.historical !== undefined) {
const {
window,
criteria: historyCriteria,
mustMatchCurrent = false,
totalMatching = '> 0',
} = options.historical
let usedCriteria: MHSCriteria;
if (historyCriteria === undefined) {
usedCriteria = this.criteria;
} else {
const {
flagged: historyFlagged = true,
confidence: historyConfidence = '>= 90',
testOn: historyTestOn = ['body']
} = historyCriteria || {};
usedCriteria = {
flagged: historyFlagged,
confidence: historyConfidence !== undefined ? parseGenericValueOrPercentComparison(historyConfidence) : undefined,
testOn: historyTestOn,
}
}
this.historical = {
criteria: usedCriteria,
window: windowConfigToWindowCriteria(window),
mustMatchCurrent,
totalMatching: parseGenericValueOrPercentComparison(totalMatching),
};
}
}
getKind(): string {
return 'mhs';
}
getSpecificPremise(): object {
return this.ogConfig;
}
protected async process(item: Submission | Comment): Promise<[boolean, RuleResult]> {
let ogResult = await this.testActivity(item, this.criteria);
let historicResults: MHSCriteriaResult[] | undefined;
let historicalCriteriaTest: string | undefined;
if (this.historical !== undefined && (!this.historical.mustMatchCurrent || ogResult.passed)) {
const {
criteria,
window,
} = this.historical;
const history = await this.resources.getAuthorActivities(item.author, window);
historicResults = await mapAsync(history, async (x: SnoowrapActivity) => await this.testActivity(x, criteria)); // history.map(x => this.testActivity(x, sentiment));
}
const logSummary: string[] = [];
let triggered = false;
let humanWindow: string | undefined;
let historicalPassed: string | undefined;
let totalMatchingText: string | undefined;
if (historicResults === undefined) {
triggered = ogResult.passed;
logSummary.push(`Current Activity MHS Test: ${ogResult.summary}`);
if (!triggered && this.historical !== undefined && this.historical.mustMatchCurrent) {
logSummary.push(`Did not check Historical because 'mustMatchCurrent' is true`);
}
} else {
const {
totalMatching,
criteria,
} = this.historical as HistoricalMHS;
historicalCriteriaTest = mhsCriteriaTestDisplay(criteria);
totalMatchingText = totalMatching.displayText;
const allResults = historicResults
const passed = allResults.filter(x => x.passed);
const firstActivity = allResults[0].activity;
const lastActivity = allResults[allResults.length - 1].activity;
const humanRange = dayjs.duration(dayjs(firstActivity.created_utc * 1000).diff(dayjs(lastActivity.created_utc * 1000))).humanize();
humanWindow = `${allResults.length} Activities (${humanRange})`;
const {operator, value, isPercent} = totalMatching;
if (isPercent) {
const passPercentVal = passed.length / allResults.length
triggered = comparisonTextOp(passPercentVal, operator, (value / 100));
historicalPassed = `${passed.length} (${formatNumber(passPercentVal)}%)`;
} else {
triggered = comparisonTextOp(passed.length, operator, value);
historicalPassed = `${passed.length}`;
}
logSummary.push(`${triggeredIndicator(triggered)} ${historicalPassed} historical activities of ${humanWindow} passed MHS criteria '${historicalCriteriaTest}' which ${triggered ? 'MET' : 'DID NOT MEET'} threshold '${totalMatching.displayText}'`);
}
const result = logSummary.join(' || ');
this.logger.verbose(result);
return Promise.resolve([triggered, this.getResult(triggered, {
result,
data: {
results: {
triggered,
criteriaTest: mhsCriteriaTestDisplay(this.criteria),
historicalCriteriaTest,
window: humanWindow,
totalMatching: totalMatchingText
}
}
})]);
}
protected async testActivity(a: SnoowrapActivity, criteria: MHSCriteria): Promise<MHSCriteriaResult> {
const content = [];
if (asComment(a)) {
content.push(a.body);
} else {
if (criteria.testOn.includes('title')) {
content.push(a.title);
}
if (criteria.testOn.includes('body') && a.is_self) {
content.push(a.selftext);
}
}
const mhsResult = await this.getMHSResponse(content.join(' '));
const {
flagged,
confidence
} = criteria;
let flaggedPassed: boolean | undefined;
let confPassed: boolean | undefined;
let summary = [];
if (confidence !== undefined) {
const {operator, value} = confidence;
confPassed = comparisonTextOp(mhsResult.confidence * 100, operator, value);
summary.push(`Confidence test (${confidence.displayText}) ${confPassed ? 'PASSED' : 'DID NOT PASS'} MHS confidence of ${formatConfidence(mhsResult.confidence)}`)
}
if (flagged !== undefined) {
flaggedPassed = flagged ? mhsResult.class === 'flag' : mhsResult.class === 'normal';
summary.push(`Flagged pass condition of ${flagged} (${flagged ? 'toxic' : 'normal'}) ${flaggedPassed ? 'MATCHED' : 'DID NOT MATCH'} MHS flag '${mhsResult.class === 'flag' ? 'toxic' : 'normal'}' ${confidence === undefined ? ` (${formatConfidence(mhsResult.confidence)} confidence)` : ''}`);
}
const passed = (flaggedPassed === undefined || flaggedPassed) && (confPassed === undefined || confPassed);
return {
activity: a,
criteria,
mhsResult,
passed,
summary: `${triggeredIndicator(passed)} ${summary.join(' | ')}`
}
}
protected async getMHSResponse(content: string): Promise<MHSResponse> {
const hash = objectHash.sha1({content});
const key = `mhs-${hash}`;
if (this.resources.wikiTTL !== false) {
let res = await this.resources.cache.get(key) as undefined | null | MHSResponse;
if(res !== undefined && res !== null) {
// don't cache bad responses
if(res.response.toLowerCase() === 'success')
{
return res;
}
}
res = await this.callMHS(content);
if(res.response.toLowerCase() === 'success') {
await this.resources.cache.set(key, res, {ttl: this.resources.wikiTTL});
}
return res;
}
return this.callMHS(content);
}
protected async callMHS(content: string): Promise<MHSResponse> {
try {
return await got.post(`https://api.moderatehatespeech.com/api/v1/moderate/`, {
headers: {
'Content-Type': `application/json`,
},
json: {
token: this.resources.thirdPartyCredentials.mhs?.apiKey,
text: content
},
}).json() as MHSResponse;
} catch (err: any) {
let error: string | undefined = undefined;
if (err instanceof HTTPError) {
error = err.response.statusMessage;
if (typeof err.response.body === 'string') {
error = `(${err.response.statusCode}) ${err.response.body}`;
}
}
throw new CMError(`MHS request failed${error !== undefined ? ` with error: ${error}` : ''}`, {cause: err});
}
}
}
const mhsCriteriaTestDisplay = (criteria: MHSCriteria) => {
const summary = [];
if (criteria.flagged !== undefined) {
summary.push(`${criteria.flagged ? 'IS FLAGGED' : 'IS NOT FLAGGED'} as toxic`);
}
if (criteria.confidence !== undefined) {
summary.push(`MHS confidence is ${criteria.confidence.displayText}`);
}
return summary.join(' AND ');
}
interface MHSResponse {
confidence: number
response: string
class: 'flag' | 'normal'
}
interface MHSCriteriaResult {
mhsResult: MHSResponse
criteria: MHSCriteria
passed: boolean
summary: string,
activity: SnoowrapActivity
}
/**
* Test the content of Activities from the Author history against MHS criteria
*
* If this is defined then the `totalMatching` threshold must pass for the Rule to trigger
*
* If `criteria` is defined here it overrides the top-level `criteria` value
*
* */
interface HistoricalMHSConfig {
window: ActivityWindowConfig
criteria?: MHSCriteriaConfig
/**
* When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history
*
* @default false
* */
mustMatchCurrent?: boolean
/**
* A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test
*
* The syntax is `(< OR > OR <= OR >=) <number>[percent sign]`
*
* * EX `> 12` => greater than 12 activities passed given `criteria` test
* * EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* @default "> 0"
* @examples ["> 0","> 10%"]
* */
totalMatching: string
}
interface HistoricalMHS extends Omit<HistoricalMHSConfig, | 'window' | 'totalMatching' | 'criteria'> {
window: ActivityWindowCriteria
criteria: MHSCriteria
totalMatching: GenericComparison
}
/**
* Criteria used to trigger based on MHS results
*
* If both `flagged` and `confidence` are specified then both conditions must pass.
*
* By default, only `flagged` is defined as `true`
* */
interface MHSCriteriaConfig {
/**
* Test if MHS considers content flagged as toxic or not
*
* @default true
* */
flagged?: boolean
/**
* A string containing a comparison operator and a value to compare against the confidence returned from MHS
*
* The syntax is `(< OR > OR <= OR >=) <number>`
*
* * EX `> 50` => MHS confidence is greater than 50%
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* @examples ["> 50"]
* */
confidence?: string
/**
* Which content from an Activity to send to MHS
*
* Only used if the Activity being tested is a Submission -- Comments can be only tested against their body
*
* If more than one type of content is specified then all text is tested together as one string
*
* @default ["body"]
* */
testOn?: ('title' | 'body')[]
}
interface MHSCriteria extends Omit<MHSCriteriaConfig, 'confidence'> {
confidence?: GenericComparison
testOn: ('title' | 'body')[]
}
interface MHSConfig {
criteria?: MHSCriteriaConfig
/**
* run MHS on Activities from the Author history
*
* If this is defined then the `totalMatching` threshold must pass for the Rule to trigger
*
* If `criteria` is defined here it overrides the top-level `criteria` value
*
* */
historical?: HistoricalMHSConfig
}
export interface MHSRuleOptions extends MHSConfig, RuleOptions {
}
/**
* Test content of an Activity against the MHS toxicity model for reddit content
*
* Running this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.
*
* More info:
*
* * https://moderatehatespeech.com/docs/
* * https://moderatehatespeech.com/
*
* */
export interface MHSRuleJSONConfig extends MHSConfig, RuleJSONConfig {
/**
* @examples ["mhs"]
* @default mhs
* */
kind: 'mhs'
}
export default MHSRule;

View File

@@ -43,6 +43,8 @@ import {
import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/ActivityWindow";
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
import {ImageHashCacheData} from "../Common/Infrastructure/Atomic";
import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils";
import {CMError} from "../Utils/Errors";
const parseLink = parseUsableLinkIdentifier();
@@ -187,8 +189,10 @@ export class RecentActivityRule extends Rule {
if (inferredSubmissionAsRef) {
if (!asSubmission(item)) {
this.logger.warn('Cannot use post as reference because triggered item is not a Submission');
viableActivity = [];
} else if (item.is_self) {
this.logger.warn('Cannot use post as reference because triggered Submission is not a link type');
viableActivity = [];
} else {
const itemId = item.id;
const referenceUrl = await item.url;
@@ -312,7 +316,7 @@ export class RecentActivityRule extends Rule {
}
} catch (err: any) {
if(!err.message.includes('did not end with a valid image extension')) {
this.logger.warn(`Will not compare image from Submission ${x.id} due to error while parsing image URL => ${err.message}`);
this.logger.warn(new CMError(`Will not compare image from Submission ${x.id} due to error while parsing image URL`, {cause: err}));
}
}
}
@@ -508,6 +512,7 @@ export class RecentActivityRule extends Rule {
testValue,
karmaThreshold,
combinedKarma,
subredditBreakdown: getSubredditBreakdownByActivityType(activities)
}
};
}

View File

@@ -12,6 +12,7 @@ import {RepostRule, RepostRuleJSONConfig} from "./RepostRule";
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
import {SentimentRule, SentimentRuleJSONConfig} from "./SentimentRule";
import {StructuredRuleConfigObject} from "../Common/Infrastructure/RuleShapes";
import {MHSRuleJSONConfig, MHSRule} from "./MHSRule";
export function ruleFactory
(config: StructuredRuleConfigObject, logger: Logger, subredditName: string, resources: SubredditResources, client: Snoowrap): Rule {
@@ -42,6 +43,9 @@ export function ruleFactory
case 'sentiment':
cfg = config as StructuredFilter<SentimentRuleJSONConfig>;
return new SentimentRule({...cfg, logger, subredditName, resources, client});
case 'mhs':
cfg = config as StructuredFilter<MHSRuleJSONConfig>;
return new MHSRule({...cfg, logger, subredditName, resources, client});
default:
throw new Error(`Rule with kind '${config.kind}' was not recognized.`);
}

View File

@@ -185,5 +185,5 @@ export interface RuleJSONConfig extends IRule {
* The kind of rule to run
* @examples ["recentActivity", "repeatActivity", "author", "attribution", "history"]
*/
kind: 'recentActivity' | 'repeatActivity' | 'author' | 'attribution' | 'history' | 'regex' | 'repost' | 'sentiment'
kind: 'recentActivity' | 'repeatActivity' | 'author' | 'attribution' | 'history' | 'regex' | 'repost' | 'sentiment' | 'mhs'
}

View File

@@ -24,7 +24,7 @@ import {RunResultEntity} from "../Common/Entities/RunResultEntity";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {RunnableBase} from "../Common/RunnableBase";
import {RunnableBaseJson, RunnableBaseOptions, StructuredRunnableBase} from "../Common/Infrastructure/Runnable";
import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterShapes";
import {FilterCriteriaDefaults, FilterCriteriaDefaultsJson} from "../Common/Infrastructure/Filters/FilterShapes";
import {IncludesData} from "../Common/Infrastructure/Includes";
export class Run extends RunnableBase {
@@ -284,7 +284,7 @@ export interface IRun extends PostBehavior, RunnableBaseJson {
*
* Default behavior is to exclude all mods and automoderator from checks
* */
filterCriteriaDefaults?: FilterCriteriaDefaults
filterCriteriaDefaults?: FilterCriteriaDefaultsJson
/**
* Use this option to override the `dryRun` setting for all Actions of all Checks in this Run
@@ -326,4 +326,5 @@ export interface RunConfigHydratedData extends IRun {
export interface RunConfigObject extends Omit<RunConfigHydratedData, 'authorIs' | 'itemIs'>, StructuredRunnableBase {
checks: ActivityCheckObject[]
filterCriteriaDefaults?: FilterCriteriaDefaults
}

View File

@@ -289,10 +289,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -763,6 +764,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -1664,13 +1747,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit"
"r/aSubreddit",
"r/{{item.subreddit}}"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -1702,18 +1785,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1752,6 +1835,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -1798,14 +1884,6 @@
"ModNoteActionJson": {
"description": "Add a Toolbox User Note to the Author of this Activity",
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"examples": [
false
],
"type": "boolean"
},
"authorIs": {
"anyOf": [
{
@@ -1856,6 +1934,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/ModNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -1928,18 +2021,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1998,6 +2091,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -2445,8 +2541,18 @@
"type": "boolean"
},
"targets": {
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
"type": "string"
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
@@ -2497,6 +2603,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -2547,6 +2735,23 @@
"is_self": {
"type": "boolean"
},
"link_flair_background_color": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_css_class": {
"anyOf": [
{
@@ -2562,7 +2767,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_text": {
"anyOf": [
@@ -2579,7 +2784,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"locked": {
"type": "boolean"
@@ -2767,7 +2972,7 @@
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
"examples": [
false
],
@@ -2823,6 +3028,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/UserNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -2890,6 +3110,23 @@
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"note": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",

View File

@@ -28,6 +28,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -665,10 +747,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -1635,6 +1718,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"$ref": "#/definitions/RuleSetConfigData"
},
@@ -1686,6 +1772,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -2180,69 +2348,6 @@
},
"type": "object"
},
"FilterCriteriaDefaults": {
"properties": {
"authorIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
},
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
}
]
},
"type": "array"
}
]
},
"authorIsBehavior": {
"enum": [
"merge",
"replace"
],
"type": "string"
},
"itemIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
},
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
}
]
},
"type": "array"
}
]
},
"itemIsBehavior": {
"description": "Determine how itemIs defaults behave when itemIs is present on the check\n\n* merge => adds defaults to check's itemIs\n* replace => check itemIs will replace defaults (no defaults used)",
"enum": [
"merge",
"replace"
],
"type": "string"
}
},
"type": "object"
},
"FilterCriteriaDefaultsJson": {
"properties": {
"authorIs": {
@@ -2313,62 +2418,6 @@
},
"type": "object"
},
"FilterOptions<AuthorCriteria>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptions<TypedActivityState>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptionsConfig<ActivityState>": {
"properties": {
"exclude": {
@@ -2872,6 +2921,55 @@
},
"type": "object"
},
"HistoricalMHSConfig": {
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
"properties": {
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"mustMatchCurrent": {
"default": false,
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
"type": "boolean"
},
"totalMatching": {
"default": "> 0",
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
"examples": [
"> 0",
"> 10%"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"totalMatching",
"window"
],
"type": "object"
},
"HistoricalSentimentConfig": {
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
"properties": {
@@ -2942,6 +3040,40 @@
"name": {
"type": "string"
},
"ratio": {
"properties": {
"threshold": {
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"threshold",
"window"
],
"type": "object"
},
"submission": {
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -3341,6 +3473,126 @@
],
"type": "object"
},
"MHSCriteriaConfig": {
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
"properties": {
"confidence": {
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
"examples": [
"> 50"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flagged": {
"default": true,
"description": "Test if MHS considers content flagged as toxic or not",
"type": "boolean"
},
"testOn": {
"default": [
"body"
],
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
"items": {
"enum": [
"body",
"title"
],
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MHSRuleJSONConfig": {
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"historical": {
"$ref": "#/definitions/HistoricalMHSConfig",
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"default": "mhs",
"description": "The kind of rule to run",
"enum": [
"mhs"
],
"examples": [
"mhs"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"examples": [
"myNewRule"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
"MessageActionJson": {
"description": "Send a private message to the Author of the Activity.",
"properties": {
@@ -3454,13 +3706,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit"
"r/aSubreddit",
"r/{{item.subreddit}}"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -3492,18 +3744,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -3542,6 +3794,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -3588,14 +3843,6 @@
"ModNoteActionJson": {
"description": "Add a Toolbox User Note to the Author of this Activity",
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"examples": [
false
],
"type": "boolean"
},
"authorIs": {
"anyOf": [
{
@@ -3646,6 +3893,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/ModNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -3718,18 +3980,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -3788,6 +4050,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -5286,6 +5551,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"type": "string"
}
@@ -5362,7 +5630,7 @@
"type": "boolean"
},
"filterCriteriaDefaults": {
"$ref": "#/definitions/FilterCriteriaDefaults",
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
},
"itemIs": {
@@ -5814,8 +6082,18 @@
"type": "boolean"
},
"targets": {
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
"type": "string"
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
@@ -6069,6 +6347,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"$ref": "#/definitions/RuleSetConfigData"
},
@@ -6120,6 +6401,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -6170,6 +6533,23 @@
"is_self": {
"type": "boolean"
},
"link_flair_background_color": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_css_class": {
"anyOf": [
{
@@ -6185,7 +6565,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_text": {
"anyOf": [
@@ -6202,7 +6582,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"locked": {
"type": "boolean"
@@ -6331,6 +6711,17 @@
"ThirdPartyCredentialsJsonConfig": {
"additionalProperties": {},
"properties": {
"mhs": {
"properties": {
"apiKey": {
"type": "string"
}
},
"required": [
"apiKey"
],
"type": "object"
},
"youtube": {
"properties": {
"apiKey": {
@@ -6463,7 +6854,7 @@
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
"examples": [
false
],
@@ -6519,6 +6910,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/UserNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -6586,6 +6992,23 @@
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"note": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",

View File

@@ -42,6 +42,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -679,10 +761,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -1458,6 +1541,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"$ref": "#/definitions/RuleSetConfigData"
},
@@ -1509,6 +1595,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -2467,6 +2635,55 @@
},
"type": "object"
},
"HistoricalMHSConfig": {
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
"properties": {
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"mustMatchCurrent": {
"default": false,
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
"type": "boolean"
},
"totalMatching": {
"default": "> 0",
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
"examples": [
"> 0",
"> 10%"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"totalMatching",
"window"
],
"type": "object"
},
"HistoricalSentimentConfig": {
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
"properties": {
@@ -2537,6 +2754,40 @@
"name": {
"type": "string"
},
"ratio": {
"properties": {
"threshold": {
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"threshold",
"window"
],
"type": "object"
},
"submission": {
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -2936,6 +3187,126 @@
],
"type": "object"
},
"MHSCriteriaConfig": {
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
"properties": {
"confidence": {
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
"examples": [
"> 50"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flagged": {
"default": true,
"description": "Test if MHS considers content flagged as toxic or not",
"type": "boolean"
},
"testOn": {
"default": [
"body"
],
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
"items": {
"enum": [
"body",
"title"
],
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MHSRuleJSONConfig": {
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"historical": {
"$ref": "#/definitions/HistoricalMHSConfig",
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"default": "mhs",
"description": "The kind of rule to run",
"enum": [
"mhs"
],
"examples": [
"mhs"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"examples": [
"myNewRule"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
"MessageActionJson": {
"description": "Send a private message to the Author of the Activity.",
"properties": {
@@ -3049,13 +3420,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit"
"r/aSubreddit",
"r/{{item.subreddit}}"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -3087,18 +3458,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -3137,6 +3508,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -3183,14 +3557,6 @@
"ModNoteActionJson": {
"description": "Add a Toolbox User Note to the Author of this Activity",
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"examples": [
false
],
"type": "boolean"
},
"authorIs": {
"anyOf": [
{
@@ -3241,6 +3607,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/ModNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -3313,18 +3694,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -3383,6 +3764,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -4741,6 +5125,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"type": "string"
}
@@ -5139,8 +5526,18 @@
"type": "boolean"
},
"targets": {
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
"type": "string"
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
@@ -5394,6 +5791,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"$ref": "#/definitions/RuleSetConfigData"
},
@@ -5445,6 +5845,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -5495,6 +5977,23 @@
"is_self": {
"type": "boolean"
},
"link_flair_background_color": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_css_class": {
"anyOf": [
{
@@ -5510,7 +6009,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_text": {
"anyOf": [
@@ -5527,7 +6026,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"locked": {
"type": "boolean"
@@ -5771,7 +6270,7 @@
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
"examples": [
false
],
@@ -5827,6 +6326,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/UserNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -5894,6 +6408,23 @@
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"note": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",

View File

@@ -133,10 +133,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -186,6 +187,17 @@
},
"BotCredentialsJsonConfig": {
"properties": {
"mhs": {
"properties": {
"apiKey": {
"type": "string"
}
},
"required": [
"apiKey"
],
"type": "object"
},
"reddit": {
"$ref": "#/definitions/RedditCredentials"
},
@@ -522,6 +534,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -874,8 +968,18 @@
"credentials": {
"$ref": "#/definitions/InfluxCredentials"
},
"debug": {
"type": "boolean"
},
"defaultTags": {
"$ref": "#/definitions/Record<string,string>"
},
"useKeepAliveAgent": {
"type": "boolean"
},
"writeOptions": {
"$ref": "#/definitions/WriteOptions",
"description": "Options used by{@linkWriteApi}."
}
},
"required": [
@@ -934,7 +1038,7 @@
"file": {
"allOf": [
{
"$ref": "#/definitions/Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"dirname\"|\"options\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">"
"$ref": "#/definitions/Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"options\"|\"dirname\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">"
},
{
"properties": {
@@ -1039,18 +1143,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1089,6 +1193,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -1140,18 +1247,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1210,6 +1317,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -1381,7 +1491,7 @@
],
"type": "object"
},
"Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"dirname\"|\"options\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">": {
"Omit<DailyRotateFileTransportOptions,\"stream\"|\"log\"|\"options\"|\"dirname\"|\"handleRejections\"|\"format\"|\"handleExceptions\"|\"logv\"|\"close\">": {
"properties": {
"auditFile": {
"description": "A string representing the name of the name of the audit file. (default: './hash-audit.json')",
@@ -1834,6 +1944,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -1884,6 +2076,23 @@
"is_self": {
"type": "boolean"
},
"link_flair_background_color": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_css_class": {
"anyOf": [
{
@@ -1899,7 +2108,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_text": {
"anyOf": [
@@ -1916,7 +2125,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"locked": {
"type": "boolean"
@@ -2048,6 +2257,17 @@
"ThirdPartyCredentialsJsonConfig": {
"additionalProperties": {},
"properties": {
"mhs": {
"properties": {
"apiKey": {
"type": "string"
}
},
"required": [
"apiKey"
],
"type": "object"
},
"youtube": {
"properties": {
"apiKey": {
@@ -2073,6 +2293,23 @@
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"note": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -2132,6 +2369,94 @@
}
},
"type": "object"
},
"WriteOptions": {
"description": "Options used by{@linkWriteApi}.",
"properties": {
"batchSize": {
"description": "max number of records/lines to send in a batch",
"type": "number"
},
"consistency": {
"description": "InfluxDB Enterprise write consistency as explained in https://docs.influxdata.com/enterprise_influxdb/v1.9/concepts/clustering/#write-consistency",
"enum": [
"all",
"any",
"one",
"quorum"
],
"type": "string"
},
"defaultTags": {
"$ref": "#/definitions/Record<string,string>",
"description": "default tags, unescaped"
},
"exponentialBase": {
"description": "base for the exponential retry delay",
"type": "number"
},
"flushInterval": {
"description": "delay between data flushes in milliseconds, at most `batch size` records are sent during flush",
"type": "number"
},
"gzipThreshold": {
"description": "When specified, write bodies larger than the threshold are gzipped",
"type": "number"
},
"headers": {
"additionalProperties": {
"type": "string"
},
"description": "HTTP headers that will be sent with every write request",
"type": "object"
},
"maxBatchBytes": {
"description": "max size of a batch in bytes",
"type": "number"
},
"maxBufferLines": {
"description": "the maximum size of retry-buffer (in lines)",
"type": "number"
},
"maxRetries": {
"description": "max count of retries after the first write fails",
"type": "number"
},
"maxRetryDelay": {
"description": "maximum delay when retrying write (milliseconds)",
"type": "number"
},
"maxRetryTime": {
"description": "max time (millis) that can be spent with retries",
"type": "number"
},
"minRetryDelay": {
"description": "minimum delay when retrying write (milliseconds)",
"type": "number"
},
"randomRetry": {
"description": "randomRetry indicates whether the next retry delay is deterministic (false) or random (true).\nThe deterministic delay starts with `minRetryDelay * exponentialBase` and it is multiplied\nby `exponentialBase` until it exceeds `maxRetryDelay`.\nWhen random is `true`, the next delay is computed as a random number between next retry attempt (upper)\nand the lower number in the deterministic sequence. `random(retryJitter)` is added to every returned value.",
"type": "boolean"
},
"retryJitter": {
"description": "add `random(retryJitter)` milliseconds delay when retrying HTTP calls",
"type": "number"
}
},
"required": [
"batchSize",
"exponentialBase",
"flushInterval",
"maxBatchBytes",
"maxBufferLines",
"maxRetries",
"maxRetryDelay",
"maxRetryTime",
"minRetryDelay",
"randomRetry",
"retryJitter"
],
"type": "object"
}
},
"description": "Configuration for application-level settings IE for running the bot instance\n\n* To load a JSON configuration **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`\n* To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`",

View File

@@ -28,6 +28,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"type": "string"
}
@@ -60,6 +63,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -594,10 +679,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -763,6 +849,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -1316,6 +1484,55 @@
},
"type": "object"
},
"HistoricalMHSConfig": {
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
"properties": {
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"mustMatchCurrent": {
"default": false,
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
"type": "boolean"
},
"totalMatching": {
"default": "> 0",
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
"examples": [
"> 0",
"> 10%"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"totalMatching",
"window"
],
"type": "object"
},
"HistoricalSentimentConfig": {
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
"properties": {
@@ -1386,6 +1603,40 @@
"name": {
"type": "string"
},
"ratio": {
"properties": {
"threshold": {
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"threshold",
"window"
],
"type": "object"
},
"submission": {
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -1693,6 +1944,126 @@
],
"type": "object"
},
"MHSCriteriaConfig": {
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
"properties": {
"confidence": {
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
"examples": [
"> 50"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flagged": {
"default": true,
"description": "Test if MHS considers content flagged as toxic or not",
"type": "boolean"
},
"testOn": {
"default": [
"body"
],
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
"items": {
"enum": [
"body",
"title"
],
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MHSRuleJSONConfig": {
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"historical": {
"$ref": "#/definitions/HistoricalMHSConfig",
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"default": "mhs",
"description": "The kind of rule to run",
"enum": [
"mhs"
],
"examples": [
"mhs"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"examples": [
"myNewRule"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
"ModLogCriteria": {
"properties": {
"action": {
@@ -1714,18 +2085,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1764,6 +2135,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -1815,18 +2189,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1885,6 +2259,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -3241,6 +3618,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -3291,6 +3750,23 @@
"is_self": {
"type": "boolean"
},
"link_flair_background_color": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_css_class": {
"anyOf": [
{
@@ -3306,7 +3782,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_text": {
"anyOf": [
@@ -3323,7 +3799,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"locked": {
"type": "boolean"
@@ -3469,6 +3945,23 @@
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"note": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",

View File

@@ -28,6 +28,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -562,10 +644,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -731,6 +814,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -1284,6 +1449,55 @@
},
"type": "object"
},
"HistoricalMHSConfig": {
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
"properties": {
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"mustMatchCurrent": {
"default": false,
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
"type": "boolean"
},
"totalMatching": {
"default": "> 0",
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
"examples": [
"> 0",
"> 10%"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"totalMatching",
"window"
],
"type": "object"
},
"HistoricalSentimentConfig": {
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
"properties": {
@@ -1354,6 +1568,40 @@
"name": {
"type": "string"
},
"ratio": {
"properties": {
"threshold": {
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"threshold",
"window"
],
"type": "object"
},
"submission": {
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -1661,6 +1909,126 @@
],
"type": "object"
},
"MHSCriteriaConfig": {
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
"properties": {
"confidence": {
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
"examples": [
"> 50"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flagged": {
"default": true,
"description": "Test if MHS considers content flagged as toxic or not",
"type": "boolean"
},
"testOn": {
"default": [
"body"
],
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
"items": {
"enum": [
"body",
"title"
],
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MHSRuleJSONConfig": {
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"historical": {
"$ref": "#/definitions/HistoricalMHSConfig",
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"default": "mhs",
"description": "The kind of rule to run",
"enum": [
"mhs"
],
"examples": [
"mhs"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"examples": [
"myNewRule"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
"ModLogCriteria": {
"properties": {
"action": {
@@ -1682,18 +2050,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1732,6 +2100,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -1783,18 +2154,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -1853,6 +2224,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -3209,6 +3583,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -3259,6 +3715,23 @@
"is_self": {
"type": "boolean"
},
"link_flair_background_color": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_css_class": {
"anyOf": [
{
@@ -3274,7 +3747,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_text": {
"anyOf": [
@@ -3291,7 +3764,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"locked": {
"type": "boolean"
@@ -3437,6 +3910,23 @@
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"note": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -3509,6 +3999,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"type": "string"
}

View File

@@ -39,6 +39,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -676,10 +758,11 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser"
"AnotherUser",
"/.*Foxx./*i"
],
"items": {
"type": "string"
@@ -1455,6 +1538,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"$ref": "#/definitions/RuleSetConfigData"
},
@@ -1506,6 +1592,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -1961,13 +2129,10 @@
},
"type": "object"
},
"FilterCriteriaDefaults": {
"FilterCriteriaDefaultsJson": {
"properties": {
"authorIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
},
{
"items": {
"anyOf": [
@@ -1976,12 +2141,19 @@
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
]
],
"description": "Determine how authorIs defaults behave when authorIs is present on the check\n\n* merge => merges defaults with check's authorIs\n* replace => check authorIs will replace defaults (no defaults used)"
},
"authorIsBehavior": {
"enum": [
@@ -1992,9 +2164,6 @@
},
"itemIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
},
{
"items": {
"anyOf": [
@@ -2006,10 +2175,16 @@
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
]
},
@@ -2024,62 +2199,6 @@
},
"type": "object"
},
"FilterOptions<AuthorCriteria>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptions<TypedActivityState>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptionsConfig<ActivityState>": {
"properties": {
"exclude": {
@@ -2583,6 +2702,55 @@
},
"type": "object"
},
"HistoricalMHSConfig": {
"description": "Test the content of Activities from the Author history against MHS criteria\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value",
"properties": {
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"mustMatchCurrent": {
"default": false,
"description": "When `true` the original Activity being checked MUST pass its criteria before the Rule considers any history",
"type": "boolean"
},
"totalMatching": {
"default": "> 0",
"description": "A string containing a comparison operator and a value to compare Activities from history that pass the given `criteria` test\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 12` => greater than 12 activities passed given `criteria` test\n* EX `<= 10%` => less than 10% of all Activities from history passed given `criteria` test",
"examples": [
"> 0",
"> 10%"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"totalMatching",
"window"
],
"type": "object"
},
"HistoricalSentimentConfig": {
"description": "Test the Sentiment of Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `sentiment` is defined here it overrides the top-level `sentiment` value",
"properties": {
@@ -2653,6 +2821,40 @@
"name": {
"type": "string"
},
"ratio": {
"properties": {
"threshold": {
"description": "A string containing a comparison operator and a value to compare number of parent criteria activities against number of \"ratio\" activities\n\nThis comparison is always done as (number of parent criteria activities) / (number of ratio activities)\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 1.2` => There are 1.2 activities from parent criteria for every 1 ratio activities\n* EX `<= 75%` => There are equal to or less than 0.75 activities from parent criteria for every 1 ratio activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*((?:\\d+)(?:(?:(?:.|,)\\d+)+)?)\\s*(%?)(.*)$",
"type": "string"
},
"window": {
"anyOf": [
{
"$ref": "#/definitions/DurationObject"
},
{
"$ref": "#/definitions/FullActivityWindowConfig"
},
{
"type": [
"string",
"number"
]
}
],
"description": "A value to define the range of Activities to retrieve.\n\nAcceptable values:\n\n**`ActivityWindowCriteria` object**\n\nAllows specify multiple range properties and more specific behavior\n\n**A `number` of Activities to retrieve**\n\n* EX `100` => 100 Activities\n\n*****\n\nAny of the below values that specify the amount of time to subtract from `NOW` to create a time range IE `NOW <---> [duration] ago`\n\nAcceptable values:\n\n**A `string` consisting of a value and a [Day.js](https://day.js.org/docs/en/durations/creating#list-of-all-available-units) time UNIT**\n\n* EX `9 days` => Range is `NOW <---> 9 days ago`\n\n**A [Day.js](https://day.js.org/docs/en/durations/creating) `object`**\n\n* EX `{\"days\": 90, \"minutes\": 15}` => Range is `NOW <---> 90 days and 15 minutes ago`\n\n**An [ISO 8601 duration](https://en.wikipedia.org/wiki/ISO_8601#Durations) `string`**\n\n* EX `PT15M` => 15 minutes => Range is `NOW <----> 15 minutes ago`",
"examples": [
"90 days"
]
}
},
"required": [
"threshold",
"window"
],
"type": "object"
},
"submission": {
"description": "A string containing a comparison operator and a value to compare **filtered** (using `include` or `exclude`, if present) submissions against\n\nThe syntax is `(< OR > OR <= OR >=) <number>[percent sign]`\n\n* EX `> 100` => greater than 100 filtered submissions\n* EX `<= 75%` => filtered submissions are equal to or less than 75% of unfiltered Activities",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -3052,6 +3254,126 @@
],
"type": "object"
},
"MHSCriteriaConfig": {
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`",
"properties": {
"confidence": {
"description": "A string containing a comparison operator and a value to compare against the confidence returned from MHS\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 50` => MHS confidence is greater than 50%",
"examples": [
"> 50"
],
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
"flagged": {
"default": true,
"description": "Test if MHS considers content flagged as toxic or not",
"type": "boolean"
},
"testOn": {
"default": [
"body"
],
"description": "Which content from an Activity to send to MHS\n\nOnly used if the Activity being tested is a Submission -- Comments can be only tested against their body\n\nIf more than one type of content is specified then all text is tested together as one string",
"items": {
"enum": [
"body",
"title"
],
"type": "string"
},
"type": "array"
}
},
"type": "object"
},
"MHSRuleJSONConfig": {
"description": "Test content of an Activity against the MHS toxicity model for reddit content\n\nRunning this Rule with no configuration will use a default configuration that will cause the Rule to trigger if MHS flags the content of the Activity as toxic.\n\nMore info:\n\n* https://moderatehatespeech.com/docs/\n* https://moderatehatespeech.com/",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"criteria": {
"$ref": "#/definitions/MHSCriteriaConfig",
"description": "Criteria used to trigger based on MHS results\n\nIf both `flagged` and `confidence` are specified then both conditions must pass.\n\nBy default, only `flagged` is defined as `true`"
},
"historical": {
"$ref": "#/definitions/HistoricalMHSConfig",
"description": "run MHS on Activities from the Author history\n\nIf this is defined then the `totalMatching` threshold must pass for the Rule to trigger\n\nIf `criteria` is defined here it overrides the top-level `criteria` value"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"default": "mhs",
"description": "The kind of rule to run",
"enum": [
"mhs"
],
"examples": [
"mhs"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"examples": [
"myNewRule"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
"MessageActionJson": {
"description": "Send a private message to the Author of the Activity.",
"properties": {
@@ -3165,13 +3487,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit"
"r/aSubreddit",
"r/{{item.subreddit}}"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -3203,18 +3525,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -3253,6 +3575,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -3299,14 +3624,6 @@
"ModNoteActionJson": {
"description": "Add a Toolbox User Note to the Author of this Activity",
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"examples": [
false
],
"type": "boolean"
},
"authorIs": {
"anyOf": [
{
@@ -3357,6 +3674,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/ModNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -3429,18 +3761,18 @@
"items": {
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
},
"type": "array"
},
{
"enum": [
"comment",
false,
"submission"
],
"type": "string"
]
}
]
},
@@ -3499,6 +3831,9 @@
}
]
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",
@@ -4857,6 +5192,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"type": "string"
}
@@ -4933,7 +5271,7 @@
"type": "boolean"
},
"filterCriteriaDefaults": {
"$ref": "#/definitions/FilterCriteriaDefaults",
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
},
"itemIs": {
@@ -5385,8 +5723,18 @@
"type": "boolean"
},
"targets": {
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
"type": "string"
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
@@ -5640,6 +5988,9 @@
{
"$ref": "#/definitions/SentimentRuleJSONConfig"
},
{
"$ref": "#/definitions/MHSRuleJSONConfig"
},
{
"$ref": "#/definitions/RuleSetConfigData"
},
@@ -5691,6 +6042,88 @@
],
"description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity"
},
"authorFlairBackgroundColor": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairCssClass": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairTemplateId": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"authorFlairText": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"createdOn": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "A relative datetime description to match the date the Activity was created\n\nMay be either:\n\n* day of the week (monday, tuesday, etc...)\n* cron expression IE `* * 15 *`\n\nSee https://crontab.guru/ for generating expressions\n\nhttps://regexr.com/6u3cc"
},
"deleted": {
"type": "boolean"
},
@@ -5741,6 +6174,23 @@
"is_self": {
"type": "boolean"
},
"link_flair_background_color": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": [
"string",
"boolean"
]
}
],
"description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_css_class": {
"anyOf": [
{
@@ -5756,7 +6206,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css"
"description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"link_flair_text": {
"anyOf": [
@@ -5773,7 +6223,7 @@
]
}
],
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text"
"description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes."
},
"locked": {
"type": "boolean"
@@ -6017,7 +6467,7 @@
"properties": {
"allowDuplicate": {
"default": false,
"description": "Add Note even if a Note already exists for this Activity",
"description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD",
"examples": [
false
],
@@ -6073,6 +6523,21 @@
],
"type": "boolean"
},
"existingNoteCheck": {
"anyOf": [
{
"$ref": "#/definitions/UserNoteCriteria"
},
{
"type": "boolean"
}
],
"default": true,
"description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added",
"examples": [
true
]
},
"itemIs": {
"anyOf": [
{
@@ -6140,6 +6605,23 @@
"pattern": "^\\s*(?<opStr>>|>=|<|<=)\\s*(?<value>\\d+)\\s*(?<percent>%?)\\s*(?<duration>in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?<extra>asc.*|desc.*)*$",
"type": "string"
},
"note": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'"
},
"referencesCurrentActivity": {
"type": "boolean"
},
"search": {
"default": "current",
"description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order",

View File

@@ -9,8 +9,15 @@ import {
createRetryHandler,
determineNewResults,
findLastIndex,
formatNumber, frequencyEqualOrLargerThanMin, getActivityAuthorName, isComment, isSubmission, likelyJson5,
mergeArr, normalizeName,
formatNumber,
frequencyEqualOrLargerThanMin,
generateFullWikiUrl,
getActivityAuthorName,
isComment,
isSubmission,
likelyJson5,
mergeArr,
normalizeName,
parseRedditEntity,
pollingInfo,
resultsSummary,
@@ -67,7 +74,7 @@ import {
isRateLimitError,
isSeriousError,
isStatusError,
RunProcessingError
RunProcessingError, SimpleError
} from "../Utils/Errors";
import {ErrorWithCause, stackWithCauses} from "pony-cause";
import {Run} from "../Run";
@@ -87,8 +94,7 @@ import {InvokeeType} from "../Common/Entities/InvokeeType";
import {RunStateType} from "../Common/Entities/RunStateType";
import {EntityRunState} from "../Common/Entities/EntityRunState/EntityRunState";
import {
ActivitySource,
DispatchSource,
ActivitySourceValue,
EventRetentionPolicyRange,
Invokee,
PollOn,
@@ -121,7 +127,7 @@ export interface runCheckOptions {
force?: boolean,
gotoContext?: string
maxGotoDepth?: number
source: ActivitySource
source: ActivitySourceValue
initialGoto?: string
activitySource: ActivitySourceData
disableDispatchDelays?: boolean
@@ -595,6 +601,34 @@ export class Manager extends EventEmitter implements RunningStates {
return this.runs.map(x => x.commentChecks);
}
async setResourceManager(config: Partial<SubredditResourceConfig> = {}) {
const {
footer,
logger = this.logger,
subreddit = this.subreddit,
caching,
credentials,
client = this.client,
botEntity = this.botEntity,
managerEntity = this.managerEntity,
statFrequency = this.statDefaults.minFrequency,
retention = this.retentionOverride,
} = config;
this.resources = await this.cacheManager.set(this.subreddit.display_name, {
footer: footer === undefined && this.resources !== undefined ? this.resources.footer : footer,
logger,
subreddit,
caching,
credentials,
client,
botEntity,
managerEntity,
statFrequency,
retention,
});
}
protected async parseConfigurationFromObject(configObj: object, suppressChangeEvent: boolean = false) {
try {
const configBuilder = new ConfigBuilder({logger: this.logger});
@@ -620,10 +654,6 @@ export class Manager extends EventEmitter implements RunningStates {
this.displayLabel = nickname || `${this.subreddit.display_name_prefixed}`;
if (footer !== undefined) {
this.resources.footer = footer;
}
this.subMaxWorkers = maxWorkers;
const realMax = this.getMaxWorkers(this.subMaxWorkers);
if(realMax !== this.queue.concurrency) {
@@ -660,9 +690,13 @@ export class Manager extends EventEmitter implements RunningStates {
statFrequency: realStatFrequency,
retention: this.retentionOverride ?? retention
};
this.resources = await this.cacheManager.set(this.subreddit.display_name, resourceConfig);
await this.setResourceManager(resourceConfig);
this.resources.setLogger(this.logger);
if (footer !== undefined && this.resources !== undefined) {
this.resources.footer = footer;
}
this.logger.info('Subreddit-specific options updated');
this.logger.info('Building Runs and Checks...');
@@ -780,39 +814,17 @@ export class Manager extends EventEmitter implements RunningStates {
try {
try {
// @ts-ignore
wiki = await this.subreddit.getWikiPage(this.wikiLocation).fetch();
wiki = await this.getWikiPage();
} catch (err: any) {
if(isStatusError(err) && err.statusCode === 404) {
// see if we can create the page
if (!this.client.scope.includes('wikiedit')) {
throw new ErrorWithCause(`Page does not exist and could not be created because Bot does not have oauth permission 'wikiedit'`, {cause: err});
if(err.cause !== undefined && isStatusError(err.cause) && err.cause.statusCode === 404) {
// try to create it
try {
wiki = await this.writeConfig('', 'Empty configuration created for ContextMod');
} catch (e: any) {
throw new CMError(`Parsing config from wiki page failed because ${err.message} AND creating empty page failed`, {cause: e});
}
const modPermissions = await this.getModPermissions();
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
throw new ErrorWithCause(`Page does not exist and could not be created because Bot not have mod permissions for creating wiki pages. Must have 'all' or 'wiki'`, {cause: err});
}
if(!this.client.scope.includes('modwiki')) {
throw new ErrorWithCause(`Bot COULD create wiki config page but WILL NOT because it does not have the oauth permissions 'modwiki' which is required to set page visibility and editing permissions. Safety first!`, {cause: err});
}
// @ts-ignore
wiki = await this.subreddit.getWikiPage(this.wikiLocation).edit({
text: '',
reason: 'Empty configuration created for ContextMod'
});
this.logger.info(`Wiki page at ${this.wikiLocation} did not exist so bot created it!`);
// 0 = use subreddit wiki permissions
// 1 = only approved wiki contributors
// 2 = only mods may edit and view
// @ts-ignore
await this.subreddit.getWikiPage(this.wikiLocation).editSettings({
permissionLevel: 2,
// don't list this page on r/[subreddit]/wiki/pages
listed: false,
});
this.logger.info('Bot set wiki page visibility to MODS ONLY');
} else {
throw err;
throw new CMError('Reading config from wiki failed', {cause: err});
}
}
const revisionDate = dayjs.unix(wiki.revision_date);
@@ -841,12 +853,7 @@ export class Manager extends EventEmitter implements RunningStates {
this.lastWikiRevision = revisionDate;
sourceData = await wiki.content_md;
} catch (err: any) {
let hint = '';
if(isStatusError(err) && err.statusCode === 403) {
hint = ` -- HINT: Either the page is restricted to mods only and the bot's reddit account does have the mod permission 'all' or 'wiki' OR the bot does not have the 'wikiread' oauth permission`;
}
const msg = `Could not read wiki configuration. Please ensure the page https://reddit.com${this.subreddit.url}wiki/${this.wikiLocation} exists and is readable${hint}`;
throw new ErrorWithCause(msg, {cause: err});
throw err;
}
if (sourceData.replace('\r\n', '').trim() === '') {
@@ -880,12 +887,13 @@ export class Manager extends EventEmitter implements RunningStates {
return true;
} catch (err: any) {
const error = new ErrorWithCause('Failed to parse subreddit configuration', {cause: err});
// @ts-ignore
//error.logged = true;
this.logger.error(error);
if(this.resources === undefined) {
// if we fail to get a valid config and there is no existing resource then just create a default one
// -- also ensures that if one already exists we don't overwrite it
await this.setResourceManager()
}
this.validConfigLoaded = false;
throw error;
throw new ErrorWithCause('Failed to parse subreddit configuration', {cause: err});
}
}
@@ -1799,6 +1807,104 @@ export class Manager extends EventEmitter implements RunningStates {
}
}
async setWikiPermissions(location: string = this.wikiLocation) {
if(!this.client.scope.includes('modwiki')) {
throw new SimpleError(`Cannot check or set permissions for wiki because bot does not have the 'modwiki' oauth permission`);
}
const settings = await this.subreddit.getWikiPage(location).getSettings();
const reasons = [];
if(settings.listed) {
reasons.push(`Page is listed (visible from r/${this.subreddit.display_name}/wiki/pages) but should be delisted.`)
}
// 0 = use subreddit wiki permissions
// 1 = only approved wiki contributors
// 2 = only mods may edit and view
if(settings.permissionLevel === 0) {
reasons.push(`Page editing level is set to 'inherit from general wiki settings' but should be set to contributors/mods only`);
}
if (reasons.length > 0) {
this.logger.debug(`Updating wiki page permissions because: ${reasons.join(' | ')}`)
// @ts-ignore
await this.subreddit.getWikiPage(location).editSettings({
permissionLevel: 2,
// don't list this page on r/[subreddit]/wiki/pages
listed: false,
});
this.logger.info('Bot set wiki page visibility to MODS ONLY and delisted the page');
}
}
async writeConfig(data: string, reason?: string, location: string = this.wikiLocation) {
const oauthErrors = [];
if (!this.client.scope.includes('wikiedit')) {
oauthErrors.push(`missing oauth permission 'wikiedit' is required to edit wiki pages`);
}
if (!this.client.scope.includes('modwiki')) {
oauthErrors.push(`missing oauth permission 'modwiki' which is required to set page visibility and editing permissions.`);
}
if(oauthErrors.length > 0) {
throw new SimpleError(`Cannot edit wiki page ${generateFullWikiUrl(this.subreddit, location)} because: ${oauthErrors.join(' | ')}`);
}
try {
// @ts-ignore
const wiki = await this.subreddit.getWikiPage(location).edit({
text: data,
reason: reason,
});
this.logger.debug(`Wrote config to ${location}`);
try {
await this.setWikiPermissions(location);
} catch (e: any) {
if (e.message.includes('modwiki')) {
this.logger.warn(e);
} else {
throw new CMError(`Successfully edited wiki page ${generateFullWikiUrl(this.subreddit, location)} but an error occurred while checking/setting page permissions`, {cause: e});
}
}
return wiki;
} catch (err: any) {
if (isStatusError(err)) {
const modPermissions = await this.getModPermissions();
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
throw new ErrorWithCause(`Could not create wiki page ${generateFullWikiUrl(this.subreddit, location)} because Bot not have mod permissions for creating wiki pages. Must have 'all' or 'wiki'`, {cause: err});
}
} else {
throw err;
}
}
}
// @ts-ignore
async getWikiPage(location: string = this.wikiLocation) {
let wiki: WikiPage;
try {
// @ts-ignore
wiki = await this.subreddit.getWikiPage(location).fetch();
} catch (err: any) {
if (isStatusError(err)) {
const error = err.statusCode === 404 ? 'does not exist' : 'is not accessible';
let reasons = [];
if (!this.client.scope.includes('wikiread')) {
reasons.push(`Bot does not have 'wikiread' oauth permission`);
} else {
const modPermissions = await this.getModPermissions();
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
reasons.push(`Bot does not have required mod permissions ('all' or 'wiki') to read restricted wiki pages`);
}
}
throw new CMError(`Wiki page ${generateFullWikiUrl(this.subreddit, location)} ${error} (${err.statusCode})${reasons.length > 0 ? ` because: ${reasons.join(' | ')}` : '.'}`, {cause: err});
} else {
throw new CMError(`Wiki page ${generateFullWikiUrl(this.subreddit, location)} could not be read`, {cause: err});
}
}
return wiki;
}
toNormalizedManager(): NormalizedManagerResponse {
return {
name: this.displayLabel,

View File

@@ -3,7 +3,7 @@ import objectHash from 'object-hash';
import {
activityIsDeleted, activityIsFiltered,
activityIsRemoved,
AuthorTypedActivitiesOptions, BOT_LINK,
AuthorTypedActivitiesOptions, BOT_LINK, TemplateContext,
getAuthorHistoryAPIOptions, renderContent
} from "../Utils/SnoowrapUtils";
import {map as mapAsync} from 'async';
@@ -41,7 +41,6 @@ import {
redisScanIterator,
removeUndefinedKeys,
shouldCacheSubredditStateCriteriaResult,
strToActivitySource,
subredditStateIsNameOnly,
testMaybeStringRegex,
toStrongSubredditState,
@@ -58,7 +57,7 @@ import {
filterByTimeRequirement,
asSubreddit,
modActionCriteriaSummary,
parseRedditFullname, asStrongImageHashCache
parseRedditFullname, asStrongImageHashCache, matchesRelativeDateTime, generateFullWikiUrl
} from "../util";
import LoggedError from "../Utils/LoggedError";
import {
@@ -113,18 +112,29 @@ import cloneDeep from "lodash/cloneDeep";
import {
asModLogCriteria,
asModNoteCriteria,
AuthorCriteria, CommentState, ModLogCriteria, ModNoteCriteria, orderedAuthorCriteriaProps, RequiredAuthorCrit,
StrongSubredditCriteria, SubmissionState,
SubredditCriteria, toFullModLogCriteria, toFullModNoteCriteria, TypedActivityState, TypedActivityStates,
AuthorCriteria,
cmToSnoowrapActivityMap,
CommentState,
ModLogCriteria,
ModNoteCriteria,
orderedAuthorCriteriaProps,
RequiredAuthorCrit,
StrongSubredditCriteria,
SubmissionState,
SubredditCriteria,
toFullModLogCriteria,
toFullModNoteCriteria, toFullUserNoteCriteria,
TypedActivityState,
TypedActivityStates,
UserNoteCriteria
} from "../Common/Infrastructure/Filters/FilterCriteria";
import {
ActivitySource, ConfigFragmentValidationFunc, DurationVal,
ActivitySourceValue, ConfigFragmentValidationFunc, DurationVal,
EventRetentionPolicyRange, ImageHashCacheData,
JoinOperands,
ModActionType,
ModeratorNameCriteria, ModUserNoteLabel, statFrequencies, StatisticFrequency,
StatisticFrequencyOption
ModeratorNameCriteria, ModUserNoteLabel, RelativeDateTimeMatch, statFrequencies, StatisticFrequency,
StatisticFrequencyOption, WikiContext
} from "../Common/Infrastructure/Atomic";
import {
AuthorOptions, FilterCriteriaPropertyResult,
@@ -145,7 +155,7 @@ import {
ActivityType,
AuthorHistorySort,
CachedFetchedActivitiesResult, FetchedActivitiesResult,
CachedFetchedActivitiesResult, FetchedActivitiesResult, MaybeActivityType,
SnoowrapActivity, SubredditRemovalReason
} from "../Common/Infrastructure/Reddit";
import {AuthorCritPropHelper} from "../Common/Infrastructure/Filters/AuthorCritPropHelper";
@@ -161,8 +171,10 @@ import {IncludesData} from "../Common/Infrastructure/Includes";
import {parseFromJsonOrYamlToObject} from "../Common/Config/ConfigUtil";
import ConfigParseError from "../Utils/ConfigParseError";
import {ActivityReport} from "../Common/Entities/ActivityReport";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {ActivitySource} from "../Common/ActivitySource";
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you have any ideas, questions, or concerns about this action.';
/**
* Only used for migrating stats from cache to db
@@ -222,7 +234,7 @@ export class SubredditResources {
protected useSubredditAuthorCache!: boolean;
protected authorTTL: number | false = cacheTTLDefaults.authorTTL;
protected subredditTTL: number | false = cacheTTLDefaults.subredditTTL;
protected wikiTTL: number | false = cacheTTLDefaults.wikiTTL;
public wikiTTL: number | false = cacheTTLDefaults.wikiTTL;
protected submissionTTL: number | false = cacheTTLDefaults.submissionTTL;
protected commentTTL: number | false = cacheTTLDefaults.commentTTL;
protected filterCriteriaTTL: number | false = cacheTTLDefaults.filterCriteriaTTL;
@@ -1140,6 +1152,18 @@ export class SubredditResources {
return mods;
}
async getSubredditModeratorPermissions(rawUserVal: RedditUser | string, rawSubredditVal?: Subreddit | string): Promise<string[]> {
const mods = await this.getSubredditModerators(rawSubredditVal);
const user = rawUserVal instanceof RedditUser ? rawUserVal.name : rawUserVal;
const mod = mods.find(x => x.name.toLowerCase() === user.toLowerCase());
if(mod === undefined) {
return [];
}
// @ts-ignore
return mod.mod_permissions as string[];
}
async getSubredditContributors(): Promise<RedditUser[]> {
const subName = this.subreddit.display_name;
const hash = `sub-${subName}-contributors`;
@@ -1208,6 +1232,156 @@ export class SubredditResources {
return false;
}
filterAuthorModActions(modActions: ModNote[], actionCriteria: (ModNoteCriteria | ModLogCriteria), referenceItem: SnoowrapActivity) {
const {search = 'current', count = '>= 1'} = actionCriteria;
const {
value,
operator,
isPercent,
duration,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration);
let actionsToUse: ModNote[] = [];
if(asModNoteCriteria(actionCriteria)) {
actionsToUse = modActions.filter(x => x.type === 'NOTE');
} else {
actionsToUse = modActions;
}
if(search === 'current' && actionsToUse.length > 0) {
actionsToUse = [actionsToUse[0]];
}
let validActions: ModNote[] = [];
if (asModLogCriteria(actionCriteria)) {
const fullCrit = toFullModLogCriteria(actionCriteria);
const fullCritEntries = Object.entries(fullCrit);
validActions = actionsToUse.filter(x => {
// filter out any notes that occur before time range
if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) {
return false;
}
for (const [k, v] of fullCritEntries) {
const key = k.toLocaleLowerCase();
if (['count', 'search'].includes(key)) {
continue;
}
switch (key) {
case 'type':
if (!v.includes((x.type as ModActionType))) {
return false
}
break;
case 'activitytype':
const anyMatch = v.some((a: MaybeActivityType) => {
switch (a) {
case 'submission':
return isSubmission(x.action.actedOn);
case 'comment':
return isComment(x.action.actedOn);
case false:
return x.action.actedOn === undefined || (!asSubmission(x.action.actedOn) && !asComment(x.action.actedOn));
}
});
if (!anyMatch) {
return false;
}
break;
case 'description':
case 'action':
case 'details':
const actionPropVal = x.action[key] as string;
if (actionPropVal === undefined) {
return false;
}
const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal));
if (!anyPropMatch) {
return false;
}
break;
case 'referencescurrentactivity':
const isCurrentActivity = x.action.actedOn !== undefined && referenceItem !== undefined && x.action.actedOn.name === referenceItem.name;
if((v === true && !isCurrentActivity) || (v === false && isCurrentActivity)) {
return false;
}
break;
} // case end
} // for each end
return true;
}); // filter end
} else if(asModNoteCriteria(actionCriteria)) {
const fullCrit = toFullModNoteCriteria(actionCriteria as ModNoteCriteria);
const fullCritEntries = Object.entries(fullCrit);
validActions = actionsToUse.filter(x => {
// filter out any notes that occur before time range
if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) {
return false;
}
for (const [k, v] of fullCritEntries) {
const key = k.toLocaleLowerCase();
if (['count', 'search'].includes(key)) {
continue;
}
switch (key) {
case 'notetype':
if (!v.map((x: ModUserNoteLabel) => x.toUpperCase()).includes((x.note.label as ModUserNoteLabel))) {
return false
}
break;
case 'note':
const actionPropVal = x.note.note;
if (actionPropVal === undefined) {
return false;
}
const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal));
if (!anyPropMatch) {
return false;
}
break;
case 'activitytype':
const anyMatch = v.some((a: MaybeActivityType) => {
switch (a) {
case 'submission':
return isSubmission(x.action.actedOn);
case 'comment':
return isComment(x.action.actedOn);
case false:
return x.action.actedOn === undefined || (!asSubmission(x.action.actedOn) && !asComment(x.action.actedOn));
}
});
if (!anyMatch) {
return false;
}
break;
case 'referencescurrentactivity':
const isCurrentActivity = x.action.actedOn !== undefined && referenceItem !== undefined && x.action.actedOn.id === referenceItem.name;
if((v === true && !isCurrentActivity) || (v === false && isCurrentActivity)) {
return false;
}
break;
} // case end
} // for each end
return true;
}); // filter end
} else {
throw new SimpleError(`Could not determine if a modActions criteria was for Mod Log or Mod Note. Given: ${JSON.stringify(actionCriteria)}`);
}
return [validActions, actionsToUse];
}
async getAuthorModNotesByActivityAuthor(activity: Comment | Submission) {
const author = activity.author instanceof RedditUser ? activity.author : getActivityAuthorName(activity.author);
if (activity.subreddit.display_name !== this.subreddit.display_name) {
@@ -1684,21 +1858,31 @@ export class SubredditResources {
return filteredListing;
}
async getExternalResource(val: string, subredditArg?: Subreddit): Promise<{val: string, fromCache: boolean, response?: Response, hash?: string}> {
const subreddit = subredditArg || this.subreddit;
let cacheKey;
const wikiContext = parseWikiContext(val);
if (wikiContext !== undefined) {
cacheKey = `${subreddit.display_name}-content-${wikiContext.wiki}${wikiContext.subreddit !== undefined ? `|${wikiContext.subreddit}` : ''}`;
}
const extUrl = wikiContext === undefined ? parseExternalUrl(val) : undefined;
if (extUrl !== undefined) {
cacheKey = extUrl;
async getExternalResource(val: string, subredditArg?: Subreddit, defaultTo: 'url' | 'wiki' | undefined = undefined): Promise<{ val: string, fromCache: boolean, response?: Response, hash?: string }> {
let wikiContext = parseWikiContext(val);
let extUrl = wikiContext === undefined ? parseExternalUrl(val) : undefined;
if (extUrl === undefined && wikiContext === undefined) {
if (defaultTo === 'url') {
extUrl = val;
} else if (defaultTo === 'wiki') {
wikiContext = {wiki: val};
}
}
if (cacheKey === undefined) {
return {val, fromCache: false, hash: cacheKey};
if (wikiContext !== undefined) {
return await this.getWikiPage(wikiContext, subredditArg !== undefined ? subredditArg.display_name : undefined);
}
if (extUrl !== undefined) {
return await this.getCachedUrlResult(extUrl);
}
return {val, fromCache: false};
}
async getCachedUrlResult(extUrl: string): Promise<{ val: string, fromCache: boolean, response?: Response, hash?: string }> {
const cacheKey = extUrl;
// try to get cached value first
if (this.wikiTTL !== false) {
@@ -1714,46 +1898,60 @@ export class SubredditResources {
}
}
let wikiContent: string;
let response: Response | undefined;
try {
const [wikiContentVal, responseVal] = await fetchExternalResult(extUrl as string, this.logger);
return {val: wikiContentVal, fromCache: false, response: responseVal, hash: cacheKey};
} catch (err: any) {
throw new CMError(`Error occurred while trying to fetch the url ${extUrl}`, {cause: err});
}
}
// no cache hit, get from source
if (wikiContext !== undefined) {
let sub;
if (wikiContext.subreddit === undefined || wikiContext.subreddit.toLowerCase() === subreddit.display_name) {
sub = subreddit;
async getWikiPage(data: WikiContext, subredditArg?: string): Promise<{ val: string, fromCache: boolean, response?: Response, hash?: string }> {
const {
subreddit = subredditArg ?? this.subreddit.display_name,
wiki
} = data;
const cacheKey = `${subreddit}-content-${wiki}${data.subreddit !== undefined ? `|${data.subreddit}` : ''}`;
if (this.wikiTTL !== false) {
await this.stats.cache.content.identifierRequestCount.set(cacheKey, (await this.stats.cache.content.identifierRequestCount.wrap(cacheKey, () => 0) as number) + 1);
this.stats.cache.content.requestTimestamps.push(Date.now());
this.stats.cache.content.requests++;
const cachedContent = await this.cache.get(cacheKey);
if (cachedContent !== undefined && cachedContent !== null) {
this.logger.debug(`Content Cache Hit: ${cacheKey}`);
return {val: cachedContent as string, fromCache: true, hash: cacheKey};
} else {
sub = this.client.getSubreddit(wikiContext.subreddit);
}
try {
// @ts-ignore
const wikiPage = sub.getWikiPage(wikiContext.wiki);
wikiContent = await wikiPage.content_md;
} catch (err: any) {
let msg = `Could not read wiki page for an unknown reason. Please ensure the page 'https://reddit.com${sub.display_name_prefixed}/wiki/${wikiContext.wiki}' exists and is readable`;
if(err.statusCode !== undefined) {
if(err.statusCode === 404) {
msg = `Could not find a wiki page at https://reddit.com${sub.display_name_prefixed}/wiki/${wikiContext.wiki} -- Reddit returned a 404`;
} else if(err.statusCode === 403 || err.statusCode === 401) {
msg = `Bot either does not have permission visibility permissions for the wiki page at https://reddit.com${sub.display_name_prefixed}wiki/${wikiContext.wiki} (due to subreddit restrictions) or the bot does have have oauth permissions to read wiki pages (operator error). Reddit returned a ${err.statusCode}`;
}
}
this.logger.error(msg, err);
throw new LoggedError(msg);
}
} else {
try {
const [wikiContentVal, responseVal] = await fetchExternalResult(extUrl as string, this.logger);
wikiContent = wikiContentVal;
response = responseVal;
} catch (err: any) {
const msg = `Error occurred while trying to fetch the url ${extUrl}`;
this.logger.error(msg, err);
throw new LoggedError(msg);
this.stats.cache.content.miss++;
}
}
return {val: wikiContent, fromCache: false, response, hash: cacheKey};
let sub = this.client.getSubreddit(subreddit);
try {
// @ts-ignore
const wikiPage = sub.getWikiPage(wiki);
const wikiContent = await wikiPage.content_md;
return {val: wikiContent, fromCache: false, hash: cacheKey};
} catch (err: any) {
if (isStatusError(err)) {
const error = err.statusCode === 404 ? 'does not exist' : 'is not accessible';
let reasons = [];
if (!this.client.scope.includes('wikiread')) {
reasons.push(`Bot does not have 'wikiread' oauth permission`);
} else {
const modPermissions = await this.getSubredditModeratorPermissions(this.botName, subreddit);
if (!modPermissions.includes('all') && !modPermissions.includes('wiki')) {
reasons.push(`Bot does not have required mod permissions ('all' or 'wiki') to read restricted wiki pages`);
}
}
throw new CMError(`Wiki page ${generateFullWikiUrl(subreddit, wiki)} ${error} (${err.statusCode})${reasons.length > 0 ? `because: ${reasons.join(' | ')}` : '.'}`, {cause: err});
} else {
throw new CMError(`Wiki page ${generateFullWikiUrl(subreddit, wiki)} could not be read`, {cause: err});
}
}
}
async getContent(val: string, subredditArg?: Subreddit): Promise<string> {
@@ -1769,9 +1967,24 @@ export class SubredditResources {
/**
* Convenience method for using getContent and SnoowrapUtils@renderContent in one method
* */
async renderContent(contentStr: string, data: SnoowrapActivity, ruleResults: RuleResultEntity[] = [], usernotes?: UserNotes) {
async renderContent(contentStr: string, activity: SnoowrapActivity, ruleResults: RuleResultEntity[] = [], actionResults: ActionResultEntity[] = [], templateData: TemplateContext = {}) {
const content = await this.getContent(contentStr);
return await renderContent(content, data, ruleResults, usernotes ?? this.userNotes);
const {usernotes = this.userNotes, ...restData} = templateData;
return await renderContent(content, {
...restData,
activity,
usernotes,
ruleResults,
actionResults,
});
}
async renderFooter(item: Submission | Comment, footer: false | string | undefined = this.footer) {
if (footer === false) {
return '';
}
return this.renderContent(footer, item);
}
async getConfigFragment<T>(includesData: IncludesData, validateFunc?: ConfigFragmentValidationFunc): Promise<T> {
@@ -1893,6 +2106,11 @@ export class SubredditResources {
includeIdentifier = false,
} = options || {};
// return early if there are no states to filter by!
if(states.length === 0) {
return items;
}
let passedItems: (Comment | Submission)[] = [];
let unpassedItems: (Comment | Submission)[] = [];
@@ -2033,7 +2251,7 @@ export class SubredditResources {
return res;
}
async testItemCriteria(i: (Comment | Submission), activityStateObj: NamedCriteria<TypedActivityState>, logger: Logger, include = true, source?: ActivitySource): Promise<FilterCriteriaResult<TypedActivityState>> {
async testItemCriteria(i: (Comment | Submission), activityStateObj: NamedCriteria<TypedActivityState>, logger: Logger, include = true, source?: ActivitySourceValue): Promise<FilterCriteriaResult<TypedActivityState>> {
const {criteria: activityState} = activityStateObj;
if(Object.keys(activityState).length === 0) {
return {
@@ -2197,7 +2415,7 @@ export class SubredditResources {
})() as boolean;
}
async isItem (item: Submission | Comment, stateCriteria: TypedActivityState, logger: Logger, include: boolean, source?: ActivitySource): Promise<FilterCriteriaResult<(SubmissionState & CommentState)>> {
async isItem (item: Submission | Comment, stateCriteria: TypedActivityState, logger: Logger, include: boolean, source?: ActivitySourceValue): Promise<FilterCriteriaResult<(SubmissionState & CommentState)>> {
//const definedStateCriteria = (removeUndefinedKeys(stateCriteria) as RequiredItemCrit);
@@ -2288,10 +2506,12 @@ export class SubredditResources {
} else {
propResultsMap.source!.found = source;
const requestedSourcesVal: string[] = !Array.isArray(itemOptVal) ? [itemOptVal] as string[] : itemOptVal as string[];
const requestedSources = requestedSourcesVal.map(x => strToActivitySource(x).toLowerCase());
const itemSource = new ActivitySource(source);
propResultsMap.source!.passed = criteriaPassWithIncludeBehavior(requestedSources.some(x => source.toLowerCase().trim() === x.toLowerCase().trim()), include);
const requestedSourcesVal: string[] = !Array.isArray(itemOptVal) ? [itemOptVal] as string[] : itemOptVal as string[];
const requestedSources = requestedSourcesVal.map(x => new ActivitySource(x));
propResultsMap.source!.passed = criteriaPassWithIncludeBehavior(requestedSources.some(x => x.matches(itemSource)), include);
break;
}
case 'score':
@@ -2427,6 +2647,23 @@ export class SubredditResources {
propResultsMap.age!.passed = criteriaPassWithIncludeBehavior(ageTest, include);
propResultsMap.age!.found = created.format('MMMM D, YYYY h:mm A Z');
break;
case 'createdOn':
const createdAt = dayjs.unix(await item.created);
propResultsMap.createdOn!.found = createdAt.format('MMMM D, YYYY h:mm A Z');
propResultsMap.createdOn!.passed = false;
const expressions = Array.isArray(itemOptVal) ? itemOptVal as RelativeDateTimeMatch[] : [itemOptVal] as RelativeDateTimeMatch[];
try {
for (const expr of expressions) {
if (matchesRelativeDateTime(expr, createdAt)) {
propResultsMap.createdOn!.passed = true;
break;
}
}
} catch(err: any) {
propResultsMap.createdOn!.reason = err.message;
}
break;
case 'title':
if(asComment(item)) {
const titleWarn ='`title` is not allowed in `itemIs` criteria when the main Activity is a Comment';
@@ -2578,13 +2815,22 @@ export class SubredditResources {
case 'flairTemplate':
case 'link_flair_text':
case 'link_flair_css_class':
if(asSubmission(item)) {
let propertyValue: string | null;
if(k === 'flairTemplate') {
propertyValue = await item.link_flair_template_id;
} else {
propertyValue = await item[k];
}
case 'link_flair_background_color':
case 'authorFlairText':
case 'authorFlairCssClass':
case 'authorFlairTemplateId':
case 'authorFlairBackgroundColor':
let actualPropName = cmToSnoowrapActivityMap[k] ?? k;
if(!asSubmission(item) && (actualPropName as string).includes('link_flair')) {
propResultsMap[k]!.passed = true;
propResultsMap[k]!.reason = `Cannot test for ${k} on Comment`;
log.warn(`Cannot test for ${k} on Comment`);
break;
} else {
// @ts-ignore
let propertyValue: string | null = await item[actualPropName];
propResultsMap[k]!.found = propertyValue;
@@ -2598,15 +2844,38 @@ export class SubredditResources {
// if crit is not a boolean but property is "empty" then it'll never pass anyway
propResultsMap[k]!.passed = !include;
} else {
const expectedValues = typeof itemOptVal === 'string' ? [itemOptVal] : (itemOptVal as string[]);
propResultsMap[k]!.passed = criteriaPassWithIncludeBehavior(expectedValues.some(x => x.trim().toLowerCase() === propertyValue?.trim().toLowerCase()), include);
// remove # if comparing hex values
const isHex = k.toLowerCase().includes('background');
const expectedValues = (typeof itemOptVal === 'string' ? [itemOptVal] : (itemOptVal as string[])).map(x => isHex ? x.replace('#','').trim() : x.trim());
const cleanProp = isHex ? propertyValue.replace('#','').trim() : propertyValue.trim();
let anyPassed = false;
const errorReasons = [];
for(const expectedVal of expectedValues) {
try {
const [regPassed] = testMaybeStringRegex(expectedVal,cleanProp);
if(regPassed) {
anyPassed = true;
}
} catch (err: any) {
if(err.message.includes('Could not convert test value')) {
errorReasons.push(`Could not convert ${expectedVal} to Regex, fallback to simple case-insenstive comparison`);
// fallback to simple comparison
anyPassed = expectedVal.toLowerCase() === cleanProp.toLowerCase();
} else {
errorReasons.push(err.message);
}
}
if(anyPassed) {
break;
}
}
if(errorReasons.length > 0) {
propResultsMap[k]!.reason = `Some errors occurred while testing: ${errorReasons.join(' | ')}`;
}
propResultsMap[k]!.passed = criteriaPassWithIncludeBehavior(anyPassed, include);
}
break;
} else {
propResultsMap[k]!.passed = true;
propResultsMap[k]!.reason = `Cannot test for ${k} on Comment`;
log.warn(`Cannot test for ${k} on Comment`);
break;
}
default:
@@ -2764,7 +3033,7 @@ export class SubredditResources {
const authPass = () => {
for (const n of nameVal) {
if (n.toLowerCase() === authorName.toLowerCase()) {
if (testMaybeStringRegex(n, authorName)[0]) {
return true;
}
}
@@ -2966,10 +3235,11 @@ export class SubredditResources {
}
break;
case 'userNotes':
const unCriterias = (authorOpts[k] as UserNoteCriteria[]).map(x => toFullUserNoteCriteria(x));
const notes = await this.userNotes.getUserNotes(item.author);
let foundNoteResult: string[] = [];
const notePass = () => {
for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) {
for (const noteCriteria of unCriterias) {
const {count = '>= 1', search = 'current', type} = noteCriteria;
const {
value,
@@ -2978,26 +3248,14 @@ export class SubredditResources {
duration,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration);
const order = extra.includes('asc') ? 'ascending' : 'descending';
switch (search) {
case 'current':
if (notes.length > 0) {
const currentNoteType = notes[notes.length - 1].noteType;
foundNoteResult.push(`Current => ${currentNoteType}`);
if (currentNoteType === type) {
return true;
}
} else {
foundNoteResult.push('No notes present');
}
break;
case 'consecutive':
if (isPercent) {
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
}
let orderedNotes = cutoffDate === undefined ? notes : notes.filter(x => x.time.isSameOrAfter(cutoffDate));
let orderedNotes = [...notes];
if (order === 'descending') {
orderedNotes = [...notes];
orderedNotes.reverse();
@@ -3005,7 +3263,7 @@ export class SubredditResources {
let currCount = 0;
let maxCount = 0;
for (const note of orderedNotes) {
if (note.noteType === type) {
if(note.matches(noteCriteria, item)) {
currCount++;
maxCount = Math.max(maxCount, currCount);
} else {
@@ -3017,8 +3275,10 @@ export class SubredditResources {
return true;
}
break;
case 'current':
case 'total':
const filteredNotes = notes.filter(x => x.noteType === type && cutoffDate === undefined || (x.time.isSameOrAfter(cutoffDate)));
const notesToUse = search === 'current' ? [notes[notes.length - 1]] : notes;
const filteredNotes = notesToUse.filter(x => x.matches(noteCriteria, item));
if (isPercent) {
// avoid divide by zero
const percent = notes.length === 0 ? 0 : filteredNotes.length / notes.length;
@@ -3028,7 +3288,7 @@ export class SubredditResources {
}
} else {
foundNoteResult.push(`${filteredNotes.length} are ${type}`);
if (comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
if (comparisonTextOp(filteredNotes.length, operator, value)) {
return true;
}
}
@@ -3056,7 +3316,6 @@ export class SubredditResources {
const {search = 'current', count = '>= 1'} = actionCriteria;
const {
value,
operator,
@@ -3064,146 +3323,10 @@ export class SubredditResources {
duration,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration);
let actionsToUse: ModNote[] = [];
if(asModNoteCriteria(actionCriteria)) {
actionsToUse = actionsToUse.filter(x => x.type === 'NOTE');
} else {
actionsToUse = modActions;
}
if(search === 'current' && actionsToUse.length > 0) {
actionsToUse = [actionsToUse[0]];
}
let validActions: ModNote[] = [];
if (asModLogCriteria(actionCriteria)) {
const fullCrit = toFullModLogCriteria(actionCriteria);
const fullCritEntries = Object.entries(fullCrit);
validActions = actionsToUse.filter(x => {
// filter out any notes that occur before time range
if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) {
return false;
}
for (const [k, v] of fullCritEntries) {
const key = k.toLocaleLowerCase();
if (['count', 'search'].includes(key)) {
continue;
}
switch (key) {
case 'type':
if (!v.includes((x.type as ModActionType))) {
return false
}
break;
case 'activitytype':
const anyMatch = v.some((a: ActivityType) => {
switch (a) {
case 'submission':
if (x.action.actedOn instanceof Submission) {
return true;
}
break;
case 'comment':
if (x.action.actedOn instanceof Comment) {
return true;
}
break;
}
});
if (!anyMatch) {
return false;
}
break;
case 'description':
case 'action':
case 'details':
const actionPropVal = x.action[key] as string;
if (actionPropVal === undefined) {
return false;
}
const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal));
if (!anyPropMatch) {
return false;
}
} // case end
} // for each end
return true;
}); // filter end
} else if(asModNoteCriteria(actionCriteria)) {
const fullCrit = toFullModNoteCriteria(actionCriteria as ModNoteCriteria);
const fullCritEntries = Object.entries(fullCrit);
validActions = actionsToUse.filter(x => {
// filter out any notes that occur before time range
if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) {
return false;
}
for (const [k, v] of fullCritEntries) {
const key = k.toLocaleLowerCase();
if (['count', 'search'].includes(key)) {
continue;
}
switch (key) {
case 'notetype':
if (!v.map((x: ModUserNoteLabel) => x.toUpperCase()).includes((x.note.label as ModUserNoteLabel))) {
return false
}
break;
case 'note':
const actionPropVal = x.note.note;
if (actionPropVal === undefined) {
return false;
}
const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal));
if (!anyPropMatch) {
return false;
}
break;
case 'activitytype':
const anyMatch = v.some((a: ActivityType) => {
switch (a) {
case 'submission':
if (x.action.actedOn instanceof Submission) {
return true;
}
break;
case 'comment':
if (x.action.actedOn instanceof Comment) {
return true;
}
break;
}
});
if (!anyMatch) {
return false;
}
break;
} // case end
} // for each end
return true;
}); // filter end
} else {
throw new SimpleError(`Could not determine if a modActions criteria was for Mod Log or Mod Note. Given: ${JSON.stringify(actionCriteria)}`);
}
const [validActions, actionsToUse] = this.filterAuthorModActions(modActions, actionCriteria, item);
switch (search) {
case 'current':
if (validActions.length === 0) {
actionResult.push('No Mod Actions present');
} else {
actionResult.push('Current Action matches criteria');
return true;
}
break;
case 'consecutive':
if (isPercent) {
throw new SimpleError(`When comparing Mod Actions with 'search: consecutive' the 'count' value cannot be a percentage. Given: ${count}`);
@@ -3230,10 +3353,11 @@ export class SubredditResources {
return true;
}
break;
case 'current':
case 'total':
if (isPercent) {
// avoid divide by zero
const percent = notes.length === 0 ? 0 : validActions.length / actionsToUse.length;
const percent = actionsToUse.length === 0 ? 0 : validActions.length / actionsToUse.length;
actionResult.push(`${formatNumber(percent)}% of ${actionsToUse.length} matched criteria`);
if (comparisonTextOp(percent, operator, value / 100)) {
return true;
@@ -3345,19 +3469,6 @@ export class SubredditResources {
this.logger.debug(`Cached check result '${result.check.name}' for User ${userName} on Submission ${item.link_id} for ${ttl} seconds (Hash ${hash})`);
}
async generateFooter(item: Submission | Comment, actionFooter?: false | string) {
let footer = actionFooter !== undefined ? actionFooter : this.footer;
if (footer === false) {
return '';
}
const subName = await item.subreddit.display_name;
const permaLink = `https://reddit.com${await item.permalink}`
const modmailLink = `https://www.reddit.com/message/compose?to=%2Fr%2F${subName}&message=${encodeURIComponent(permaLink)}`
const footerRawContent = await this.getContent(footer, item.subreddit);
return he.decode(Mustache.render(footerRawContent, {subName, permaLink, modmailLink, botLink: BOT_LINK}));
}
async getImageHash(img: ImageData): Promise<Required<ImageHashCacheData>|undefined> {
if(img.hashResult !== undefined && img.hashResultFlipped !== undefined) {
@@ -3725,7 +3836,7 @@ export const checkAuthorFilter = async (item: (Submission | Comment), filter: Au
return [true, undefined, {criteriaResults: allCritResults, join: 'OR', passed: true}];
}
export const checkItemFilter = async (item: (Submission | Comment), filter: ItemOptions, resources: SubredditResources, options?: {logger?: Logger, source?: ActivitySource, includeIdentifier?: boolean}): Promise<[boolean, ('inclusive' | 'exclusive' | undefined), FilterResult<TypedActivityState>]> => {
export const checkItemFilter = async (item: (Submission | Comment), filter: ItemOptions, resources: SubredditResources, options?: {logger?: Logger, source?: ActivitySourceValue, includeIdentifier?: boolean}): Promise<[boolean, ('inclusive' | 'exclusive' | undefined), FilterResult<TypedActivityState>]> => {
const {
logger: parentLogger = NoopLogger,
@@ -3883,7 +3994,7 @@ export const checkItemFilter = async (item: (Submission | Comment), filter: Item
return [true, undefined, {criteriaResults: allCritResults, join: 'OR', passed: true}];
}
export const checkCommentSubmissionStates = async (item: Comment, submissionStates: SubmissionState[], resources: SubredditResources, logger: Logger, source?: ActivitySource, excludeCondition?: JoinOperands): Promise<[boolean, FilterCriteriaPropertyResult<CommentState>]> => {
export const checkCommentSubmissionStates = async (item: Comment, submissionStates: SubmissionState[], resources: SubredditResources, logger: Logger, source?: ActivitySourceValue, excludeCondition?: JoinOperands): Promise<[boolean, FilterCriteriaPropertyResult<CommentState>]> => {
// test submission state first since it's more likely(??) we have crit results or cache data for this submission than for the comment
// get submission

View File

@@ -16,6 +16,9 @@ import {Cache} from 'cache-manager';
import {isScopeError} from "../Utils/Errors";
import {ErrorWithCause} from "pony-cause";
import {UserNoteType} from "../Common/Infrastructure/Atomic";
import {FullUserNoteCriteria, UserNoteCriteria} from "../Common/Infrastructure/Filters/FilterCriteria";
import {parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
interface RawUserNotesPayload {
ver: number,
@@ -251,6 +254,44 @@ export class UserNote {
}
public matches(criteria: FullUserNoteCriteria, item?: SnoowrapActivity) {
if (criteria.type !== undefined) {
if(typeof this.noteType === 'string') {
if(this.noteType.toLowerCase() !== criteria.type.toLowerCase().trim()) {
return false
}
} else {
return false;
}
}
if (criteria.note !== undefined && !criteria.note.some(x => x.test(this.text ?? ''))) {
return false;
}
if(criteria.referencesCurrentActivity !== undefined) {
if(criteria.referencesCurrentActivity) {
if(item === undefined) {
return false;
}
if(this.link === null) {
return false;
}
if(!this.link.includes(item.id)) {
return false;
}
} else if(this.link !== null && item !== undefined && this.link.includes(item.id)) {
return false;
}
}
const {duration} = parseGenericValueOrPercentComparison(criteria.count ?? '>= 1');
if (duration !== undefined) {
const cutoffDate = dayjs().subtract(duration);
if (this.time.isSameOrAfter(cutoffDate)) {
return false;
}
}
return true;
}
public toRaw(constants: UserNotesConstants): RawNote {
let m = this.modIndex;
if(m === undefined && this.moderator !== undefined) {

View File

@@ -3,7 +3,7 @@ import {Submission, Subreddit, Comment} from "snoowrap/dist/objects";
import {parseSubredditName} from "../util";
import {ModUserNoteLabel} from "../Common/Infrastructure/Atomic";
import {CreateModNoteData, ModNote, ModNoteRaw, ModNoteSnoowrapPopulated} from "../Subreddit/ModNotes/ModNote";
import {CMError, SimpleError} from "./Errors";
import {CMError, isStatusError, SimpleError} from "./Errors";
import {RawSubredditRemovalReasonData, SnoowrapActivity} from "../Common/Infrastructure/Reddit";
// const proxyFactory = (endpoint: string) => {
@@ -66,6 +66,28 @@ export class ExtendedSnoowrap extends Snoowrap {
return await this.oauthRequest({uri: '/api/info', method: 'get', qs: { sr_name: names.join(',')}}) as Listing<Subreddit>;
}
async subredditExists(name: string): Promise<[boolean, Subreddit?]> {
try {
// @ts-ignore
const sub = await this.getSubreddit(name).fetch();
return [true, sub];
} catch (e: any) {
if (isStatusError(e)) {
switch (e.statusCode) {
case 403:
// we know that the sub exists but it is private
return [true, undefined];
case 404:
return [false, undefined];
default:
throw e;
}
} else {
throw e;
}
}
}
async assignUserFlairByTemplateId(options: { flairTemplateId: string, username: string, subredditName: string }): Promise<any> {
return await this.oauthRequest({
uri: `/r/${options.subredditName}/api/selectflair`,

View File

@@ -15,6 +15,7 @@ import {
asStrongSubredditState,
asSubmission,
convertSubredditsRawToStrong,
formatNumber,
getActivityAuthorName,
getActivitySubredditName,
isStrongSubredditState, isSubmission,
@@ -22,7 +23,7 @@ import {
normalizeName,
parseDurationValToDuration,
parseRedditEntity,
parseRuleResultsToMarkdownSummary, removeUndefinedKeys,
parseResultsToMarkdownSummary, removeUndefinedKeys,
subredditStateIsNameOnly,
toStrongSubredditState,
truncateStringToLength,
@@ -34,8 +35,14 @@ import {URL} from "url";
import {isStatusError, MaybeSeriousErrorWithCause, SimpleError} from "./Errors";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {StrongSubredditCriteria, SubredditCriteria} from "../Common/Infrastructure/Filters/FilterCriteria";
import {DurationVal} from "../Common/Infrastructure/Atomic";
import {DurationVal, GenericContentTemplateData} from "../Common/Infrastructure/Atomic";
import {ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
import {
SnoowrapActivity,
SubredditActivityAbsoluteBreakdown,
SubredditActivityBreakdown, SubredditActivityBreakdownByType
} from "../Common/Infrastructure/Reddit";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export const BOT_LINK = 'https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot';
@@ -119,73 +126,184 @@ export const isSubreddit = async (subreddit: Subreddit, stateCriteria: Subreddit
const renderContentCommentTruncate = truncateStringToLength(50);
const shortTitleTruncate = truncateStringToLength(15);
export const renderContent = async (template: string, data: (Submission | Comment), ruleResults: RuleResultEntity[] = [], usernotes: UserNotes) => {
const conditional: any = {};
if(data.can_mod_post) {
conditional.reports = data.num_reports;
conditional.modReports = data.mod_reports.length;
conditional.userReports = data.user_reports.length;
}
if(asSubmission(data)) {
conditional.nsfw = data.over_18;
conditional.spoiler = data.spoiler;
conditional.op = true;
conditional.upvoteRatio = `${data.upvote_ratio * 100}%`;
} else {
conditional.op = data.is_submitter;
}
const templateData: any = {
kind: data instanceof Submission ? 'submission' : 'comment',
// @ts-ignore
author: getActivityAuthorName(await data.author),
votes: data.score,
age: dayjs.duration(dayjs().diff(dayjs.unix(data.created))).humanize(),
permalink: `https://reddit.com${data.permalink}`,
export interface TemplateContext {
usernotes?: UserNotes
check?: string
manager?: string
ruleResults?: RuleResultEntity[]
actionResults?: ActionResultEntity[]
activity?: SnoowrapActivity
[key: string]: any
}
export const renderContent = async (template: string, data: TemplateContext = {}) => {
const {
usernotes,
ruleResults,
actionResults,
activity,
...restContext
} = data;
let view: GenericContentTemplateData = {
botLink: BOT_LINK,
id: data.name,
...conditional
...restContext
};
if(activity !== undefined) {
const conditional: any = {};
if (activity.can_mod_post) {
conditional.reports = activity.num_reports;
conditional.modReports = activity.mod_reports.length;
conditional.userReports = activity.user_reports.length;
}
if (asSubmission(activity)) {
conditional.nsfw = activity.over_18;
conditional.spoiler = activity.spoiler;
conditional.op = true;
conditional.upvoteRatio = `${activity.upvote_ratio * 100}%`;
} else {
conditional.op = activity.is_submitter;
}
const subreddit = activity.subreddit.display_name;
const permalink = `https://reddit.com${activity.permalink}`;
view.modmailLink = `https://www.reddit.com/message/compose?to=%2Fr%2F${subreddit}&message=${encodeURIComponent(permalink)}`;
const templateData: any = {
kind: activity instanceof Submission ? 'submission' : 'comment',
// @ts-ignore
author: getActivityAuthorName(await activity.author),
votes: activity.score,
age: dayjs.duration(dayjs().diff(dayjs.unix(activity.created))).humanize(),
permalink,
id: activity.name,
subreddit,
...conditional
}
if (template.includes('{{item.notes') && usernotes !== undefined) {
// we need to get notes
const notesData = await usernotes.getUserNotes(activity.author);
// return usable notes data with some stats
const current = notesData.length > 0 ? notesData[notesData.length - 1] : undefined;
// group by type
const grouped = notesData.reduce((acc: any, x) => {
const {[x.noteType]: nt = []} = acc;
return Object.assign(acc, {[x.noteType]: nt.concat(x)});
}, {});
templateData.notes = {
data: notesData,
current,
...grouped,
};
}
if (activity instanceof Submission) {
templateData.url = activity.url;
templateData.title = activity.title;
templateData.shortTitle = shortTitleTruncate(activity.title);
} else {
templateData.title = renderContentCommentTruncate(activity.body);
templateData.shortTitle = shortTitleTruncate(activity.body);
}
view.item = templateData;
}
if (template.includes('{{item.notes')) {
// we need to get notes
const notesData = await usernotes.getUserNotes(data.author);
// return usable notes data with some stats
const current = notesData.length > 0 ? notesData[notesData.length - 1] : undefined;
// group by type
const grouped = notesData.reduce((acc: any, x) => {
const {[x.noteType]: nt = []} = acc;
return Object.assign(acc, {[x.noteType]: nt.concat(x)});
}, {});
templateData.notes = {
data: notesData,
current,
...grouped,
if(ruleResults !== undefined) {
view = {
...view,
...parseRuleResultForTemplate(ruleResults)
}
}
if(actionResults !== undefined) {
view = {
...view,
...parseActionResultForTemplate(actionResults)
}
}
const rendered = Mustache.render(template, view) as string;
return he.decode(rendered);
}
export const parseActionResultForTemplate = (actionResults: ActionResultEntity[] = []) => {
// normalize rule names and map context data
// NOTE: we are relying on users to use unique names for action. If they don't only the last action run of kind X will have its results here
const normalizedActionResults = actionResults.reduce((acc: object, actionResult) => {
const {
success,
data:{
...restData
} = {},
result,
} = actionResult;
let name = actionResult.premise.name;
const kind = actionResult.premise.kind.name;
if(name === undefined || name === null) {
name = kind;
}
let formattedData: any = {};
// remove all non-alphanumeric characters (spaces, dashes, underscore) and set to lowercase
// we will set this as the rule property name to make it easy to access results from mustache template
const normalName = normalizeName(name);
return {
...acc, [normalName]: {
kind,
success,
result,
...restData,
...formattedData,
}
};
}
if (data instanceof Submission) {
templateData.url = data.url;
templateData.title = data.title;
templateData.shortTitle = shortTitleTruncate(data.title);
} else {
templateData.title = renderContentCommentTruncate(data.body);
templateData.shortTitle = shortTitleTruncate(data.body);
}
}, {});
return {
actionSummary: parseResultsToMarkdownSummary(actionResults),
actions: normalizedActionResults
};
}
export const parseRuleResultForTemplate = (ruleResults: RuleResultEntity[] = []) => {
// normalize rule names and map context data
// NOTE: we are relying on users to use unique names for rules. If they don't only the last rule run of kind X will have its results here
const normalizedRuleResults = ruleResults.reduce((acc: object, ruleResult) => {
const {
//name,
triggered,
data = {},
data:{
subredditBreakdown,
...restData
} = {},
result,
// premise: {
// kind
// }
} = ruleResult;
let name = ruleResult.premise.name;
const kind = ruleResult.premise.kind.name;
if(name === undefined || name === null) {
name = kind;
}
let formattedData: any = {};
if (subredditBreakdown !== undefined) {
// format breakdown for markdown
if (Array.isArray(subredditBreakdown)) {
const bdArr = subredditBreakdown as SubredditActivityBreakdown[];
formattedData.subredditBreakdownFormatted = formatSubredditBreakdownAsMarkdownList(bdArr);
} else {
const bd = subredditBreakdown as SubredditActivityBreakdownByType;
// default to total
formattedData.subredditBreakdownFormatted = formatSubredditBreakdownAsMarkdownList(bd.total);
const formatted = Object.entries((bd)).reduce((acc: { [key: string]: string }, curr) => {
const [name, breakdownData] = curr;
acc[`${name}Formatted`] = formatSubredditBreakdownAsMarkdownList(breakdownData);
return acc;
}, {});
formattedData.subredditBreakdown = {...bd, ...formatted};
}
}
// remove all non-alphanumeric characters (spaces, dashes, underscore) and set to lowercase
// we will set this as the rule property name to make it easy to access results from mustache template
const normalName = normalizeName(name);
@@ -194,14 +312,16 @@ export const renderContent = async (template: string, data: (Submission | Commen
kind,
triggered,
result,
...data,
...restData,
...formattedData,
}
};
}, {});
const view = {item: templateData, ruleSummary: parseRuleResultsToMarkdownSummary(ruleResults), rules: normalizedRuleResults};
const rendered = Mustache.render(template, view) as string;
return he.decode(rendered);
return {
ruleSummary: parseResultsToMarkdownSummary(ruleResults),
rules: normalizedRuleResults
};
}
export interface ItemContent {
@@ -391,3 +511,58 @@ export const getAuthorHistoryAPIOptions = (val: any) => {
return opts;
}
export const getSubredditBreakdown = (activities: SnoowrapActivity[] = []): SubredditActivityBreakdown[] => {
if(activities.length === 0) {
return [];
}
const total = activities.length;
const countBd = activities.reduce((acc: { [key: string]: number }, curr) => {
const subName = curr.subreddit.display_name;
if (acc[subName] === undefined) {
acc[subName] = 0;
}
acc[subName]++;
return acc;
}, {});
const breakdown: SubredditActivityBreakdown[] = Object.entries(countBd).reduce((acc, curr) => {
const [name, count] = curr;
return acc.concat(
{
name,
count,
percent: Number.parseFloat(formatNumber((count / total) * 100))
}
);
}, ([] as SubredditActivityBreakdown[]));
return breakdown;
}
export const getSubredditBreakdownByActivityType = (activities: SnoowrapActivity[]): SubredditActivityBreakdownByType => {
return {
total: getSubredditBreakdown(activities),
submission: getSubredditBreakdown(activities.filter(x => x instanceof Submission)),
comment: getSubredditBreakdown(activities.filter(x => x instanceof Comment)),
}
}
export const formatSubredditBreakdownAsMarkdownList = (data: SubredditActivityBreakdown[] = []): string => {
if(data.length === 0) {
return '';
}
data.sort((a, b) => b.count - a.count);
const bd = data.map(x => {
const entity = parseRedditEntity(x.name);
const prefixedName = entity.type === 'subreddit' ? `r/${entity.name}` : `u/${entity.name}`;
return `* ${prefixedName} - ${x.count} (${x.percent}%)`
}).join('\n');
return `${bd}\n`;
}

View File

@@ -1,6 +1,6 @@
import {
BotInstance,
BotInstanceResponse,
BotInstanceResponse, BotSubredditInviteResponse,
CMInstanceInterface,
ManagerResponse,
NormalizedManagerResponse
@@ -15,6 +15,7 @@ export class ClientBotInstance implements BotInstance {
managers: NormalizedManagerResponse[];
nanny?: string | undefined;
running: boolean;
invites: BotSubredditInviteResponse[]
constructor(data: BotInstanceResponse, instance: CMInstanceInterface) {
this.instance = instance;
@@ -24,6 +25,7 @@ export class ClientBotInstance implements BotInstance {
this.managers = data.managers.map(x => ({...x, subredditNormal: parseRedditEntity(x.subreddit).name}));
this.nanny = data.nanny;
this.running = data.running;
this.invites = data.invites === undefined || data.invites === null ? [] : data.invites;
}
getManagerNames(): string[] {
@@ -56,6 +58,14 @@ export class ClientBotInstance implements BotInstance {
return this.getAccessibleSubreddits(user, subreddits).includes(parseRedditEntity(subreddit).name);
}
getInvites() {
return this.invites;
}
getInvite(val: string) {
return this.invites.find(x => x.id === val);
}
}
export default ClientBotInstance;

View File

@@ -33,7 +33,7 @@ import tcpUsed from "tcp-port-used";
import http from "http";
import jwt from 'jsonwebtoken';
import {Server as SocketServer} from "socket.io";
import got from 'got';
import got, {HTTPError} from 'got';
import sharedSession from "express-socket.io-session";
import dayjs from "dayjs";
import httpProxy from 'http-proxy';
@@ -56,7 +56,13 @@ import {MigrationService} from "../../Common/MigrationService";
import {RuleResultEntity} from "../../Common/Entities/RuleResultEntity";
import {RuleSetResultEntity} from "../../Common/Entities/RuleSetResultEntity";
import { PaginationAwareObject } from "../Common/util";
import {BotInstance, BotStatusResponse, CMInstanceInterface, InviteData} from "../Common/interfaces";
import {
BotInstance,
BotStatusResponse,
BotSubredditInviteResponse,
CMInstanceInterface, HeartbeatResponse,
InviteData, SubredditInviteDataPersisted
} from "../Common/interfaces";
import {open} from "fs/promises";
const emitter = new EventEmitter();
@@ -593,7 +599,18 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
next();
}
app.getAsync('/auth/helper', helperAuthed, instanceWithPermissions, instancesViewData, (req, res) => {
const initHeartbeat = async (req: express.Request, res: express.Response, next: Function) => {
if(!init) {
for(const c of clients) {
await refreshClient(c);
}
init = true;
loopHeartbeat();
}
next();
};
app.getAsync('/auth/helper', initHeartbeat, helperAuthed, instanceWithPermissions, instancesViewData, (req, res) => {
return res.render('helper', {
redirectUri: clientCredentials.redirectUri,
clientId: clientCredentials.clientId,
@@ -604,7 +621,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
});
});
app.getAsync('/auth/invite/:inviteId', async (req, res) => {
app.getAsync('/auth/invite/:inviteId', initHeartbeat, async (req, res) => {
const {inviteId} = req.params;
if (inviteId === undefined) {
@@ -699,7 +716,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
}
});
app.getAsync('/auth/init/:inviteId', async (req: express.Request, res: express.Response) => {
app.getAsync('/auth/init/:inviteId', initHeartbeat, async (req: express.Request, res: express.Response) => {
const { inviteId } = req.params;
if(inviteId === undefined) {
return res.render('error', {error: '`invite` param is missing from URL'});
@@ -822,6 +839,8 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
next();
}
// const authenticatedRouter = Router();
// authenticatedRouter.use([ensureAuthenticated, defaultSession]);
// app.use(authenticatedRouter);
@@ -837,7 +856,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
// logger.debug(`Got proxy response: ${res.statusCode} for ${req.url}`);
// });
app.useAsync('/api/', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(false), createUserToken], (req: express.Request, res: express.Response) => {
app.useAsync('/api/', [ensureAuthenticatedApi, initHeartbeat, defaultSession, instanceWithPermissions, botWithPermissions(false), createUserToken], (req: express.Request, res: express.Response) => {
req.headers.Authorization = `Bearer ${req.token}`
const instance = req.instance as CMInstanceInterface;
@@ -889,17 +908,6 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
next();
}*/
const initHeartbeat = async (req: express.Request, res: express.Response, next: Function) => {
if(!init) {
for(const c of clients) {
await refreshClient(c);
}
init = true;
loopHeartbeat();
}
next();
};
const redirectBotsNotAuthed = async (req: express.Request, res: express.Response, next: Function) => {
if(cmInstances.length === 1 && cmInstances[0].error === 'Missing credentials: refreshToken, accessToken') {
// assuming user is doing first-time setup and this is the default localhost bot
@@ -1029,6 +1037,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
...req.instancesViewData,
bots: resp.bots,
now: dayjs().add(1, 'minute').format('YYYY-MM-DDTHH:mm'),
defaultExpire: dayjs().add(1, 'day').format('YYYY-MM-DDTHH:mm'),
botId: (req.instance as CMInstance).getName(),
isOperator: isOp,
system: isOp ? {
@@ -1045,8 +1054,139 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
});
});
app.getAsync('/bot/invites', defaultSession, async (req: express.Request, res: express.Response) => {
res.render('modInvites', {
app.getAsync('/bot/invites/subreddit/:inviteId', initHeartbeat, ensureAuthenticated, defaultSession, async (req: express.Request, res: express.Response) => {
const {inviteId} = req.params;
if (inviteId === undefined) {
return res.render('error', {error: '`invite` param is missing from URL'});
}
let validInstance: CMInstance | undefined = undefined;
let validInvite: BotSubredditInviteResponse | undefined = undefined;
let validBot: BotInstance | undefined = undefined;
for(const instance of cmInstances) {
for(const bot of instance.bots) {
validInvite = bot.getInvite(inviteId);
if(validInvite !== undefined) {
validInstance = instance;
validBot = bot;
break;
}
}
}
if(validInvite === undefined) {
// try refreshing clients first
await refreshClients(true);
}
for(const instance of cmInstances) {
for(const bot of instance.bots) {
validInvite = bot.getInvite(inviteId);
if(validInvite !== undefined) {
validInstance = instance;
validBot = bot;
break;
}
}
}
if(validInvite === undefined || validInstance === undefined || validBot === undefined) {
return res.render('error', {error: 'Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.'});
}
const user = req.user as Express.User;
// @ts-ignore
if(!user.subreddits.some(x => x.toLowerCase() === validInvite.subreddit.toLowerCase())) {
return res.render('error', {error: 'Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.'});
}
try {
const invite = await got.get(`${validInstance.normalUrl}/bot/invite/${validInvite.id}?bot=${validBot.botName}`, {
headers: {
'Authorization': `Bearer ${validInstance.getToken()}`,
}
}).json() as SubredditInviteDataPersisted;
const {guests, ...rest} = invite;
const guestStr = guests !== undefined && guests !== null && guests.length > 0 ? guests.join(',') : '';
return res.render('subredditOnboard/onboard', {
invite: {...rest, guests: guestStr},
bot: validBot.botName,
title: `Subreddit Onboarding`,
});
} catch (err: any) {
logger.error(err);
return res.render('error', {error: `Error occurred while retriving invite data: ${err.message}`});
}
});
app.postAsync('/bot/invites/subreddit/:inviteId', ensureAuthenticated, defaultSession, async (req: express.Request, res: express.Response) => {
const {inviteId} = req.params;
if (inviteId === undefined) {
return res.status(400).send('`invite` param is missing from URL')
}
let validInstance: CMInstance | undefined = undefined;
let validInvite: BotSubredditInviteResponse | undefined = undefined;
let validBot: BotInstance | undefined = undefined;
for(const instance of cmInstances) {
for(const bot of instance.bots) {
validInvite = bot.getInvite(inviteId);
if(validInvite !== undefined) {
validInstance = instance;
validBot = bot;
break;
}
}
}
if(validInvite === undefined || validInstance === undefined || validBot === undefined) {
return res.status(400).send('Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.')
}
const user = req.user as Express.User;
// @ts-ignore
if(!user.subreddits.some(x => x.toLowerCase() === validInvite.subreddit.toLowerCase())) {
return res.status(400).send('Either no invite exists with the given ID or you are not a moderator of the subreddit this invite is for.')
}
try {
await got.post(`${validInstance.normalUrl}/bot/invite/${validInvite.id}?bot=${validBot.botName}`, {
json: req.body,
headers: {
'Authorization': `Bearer ${validInstance.getToken()}`,
}
})
return res.status(200);
} catch (err: any) {
logger.error(err);
res.status(500)
let msg = err.message;
if(err instanceof HTTPError && typeof err.response.body === 'string') {
msg = err.response.body
}
return res.send(msg);
}
});
app.getAsync('/bot/invites/subreddit', initHeartbeat, ensureAuthenticated, defaultSession, instanceWithPermissions, botWithPermissions(true), async (req: express.Request, res: express.Response) => {
res.render('subredditOnboard/helper', {
title: `Create Subreddit Invite`,
});
});
app.getAsync('/bot/invites', initHeartbeat, ensureAuthenticated, defaultSession, async (req: express.Request, res: express.Response) => {
res.render('subredditOnboard/manager', {
title: `Pending Moderation Invites`,
});
});
@@ -1060,7 +1200,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
});
});
app.getAsync('/guest', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(true)], async (req: express.Request, res: express.Response) => {
app.getAsync('/guest', [ensureAuthenticatedApi, initHeartbeat, defaultSession, instanceWithPermissions, botWithPermissions(true)], async (req: express.Request, res: express.Response) => {
const {subreddit} = req.query as any;
return res.status(req.user?.isSubredditGuest(req.bot, subreddit) ? 200 : 403).send();
});
@@ -1106,7 +1246,7 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
return res.send();
});
app.getAsync('/events', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(true), createUserToken], async (req: express.Request, res: express.Response) => {
app.getAsync('/events', [ensureAuthenticatedApi, initHeartbeat, defaultSession, instanceWithPermissions, botWithPermissions(true), createUserToken], async (req: express.Request, res: express.Response) => {
const {subreddit, page = 1, permalink, related, author} = req.query as any;
const resp = await got.get(`${(req.instance as CMInstanceInterface).normalUrl}/events`, {
headers: {
@@ -1462,27 +1602,6 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
}
emitter.on('log', botWebLogListener);
socketListeners.set(socket.id, [...(socketListeners.get(socket.id) || []), botWebLogListener]);
// only setup streams if the user can actually access them (not just a web operator)
if(session.authBotId !== undefined) {
// streaming stats from client
const newStreams: (AbortController | NodeJS.Timeout)[] = [];
const interval = setInterval(async () => {
try {
const resp = await got.get(`${bot.normalUrl}/stats`, {
headers: {
'Authorization': `Bearer ${createToken(bot, user)}`,
}
}).json() as object;
io.to(session.id).emit('opStats', resp);
} catch (err: any) {
bot.logger.error(new ErrorWithCause('Could not retrieve stats', {cause: err}));
clearInterval(interval);
}
}, 5000);
newStreams.push(interval);
sockStreams.set(socket.id, newStreams);
}
}
}
}
@@ -1495,14 +1614,18 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
const loopHeartbeat = async () => {
while(true) {
for(const c of clients) {
await refreshClient(c);
}
await refreshClients();
// sleep for 10 seconds then do heartbeat check again
await sleep(10000);
}
}
const refreshClients = async (force = false) => {
for(const c of clients) {
await refreshClient(c, force);
}
}
const addBot = async (inviteId: string, botPayload: any) => {
const cmInstance = cmInstances.find(x => x.invites.includes(inviteId));

View File

@@ -16,24 +16,24 @@ class ServerUser extends CMUser<App, Bot, Manager> {
}
canAccessInstance(val: App): boolean {
return this.isOperator || val.bots.filter(x => x.canUserAccessBot(this.name, this.subreddits)).length > 0;
return this.isOperator || this.machine || val.bots.filter(x => x.canUserAccessBot(this.name, this.subreddits)).length > 0;
}
canAccessBot(val: Bot): boolean {
return this.isOperator || val.canUserAccessBot(this.name, this.subreddits);
return this.isOperator || this.machine || val.canUserAccessBot(this.name, this.subreddits);
}
accessibleBots(bots: Bot[]): Bot[] {
return this.isOperator ? bots : bots.filter(x => x.canUserAccessBot(this.name, this.subreddits));
return (this.isOperator || this.machine) ? bots : bots.filter(x => x.canUserAccessBot(this.name, this.subreddits));
}
canAccessSubreddit(val: Bot, name: string): boolean {
const normalName = parseRedditEntity(name).name;
return this.isOperator || this.accessibleSubreddits(val).some(x => x.toNormalizedManager().subredditNormal === normalName);
return this.isOperator || this.machine || this.accessibleSubreddits(val).some(x => x.toNormalizedManager().subredditNormal === normalName);
}
accessibleSubreddits(bot: Bot): Manager[] {
if(this.isOperator) {
if(this.isOperator || this.machine) {
return bot.subManagers;
}

View File

@@ -3,6 +3,7 @@ import {BotConnection, LogInfo, ManagerStats} from "../../Common/interfaces";
import {Guest, GuestAll} from "../../Common/Entities/Guest/GuestInterfaces";
import {URL} from "url";
import {Dayjs} from "dayjs";
import {Subreddit} from "snoowrap/dist/objects";
export interface BotStats {
startedAtHuman: string,
@@ -90,6 +91,10 @@ export interface NormalizedManagerResponse extends ManagerResponse {
subredditNormal: string
}
export interface BotSubredditInviteResponse {
subreddit: string
id: string
}
export interface BotInstanceResponse {
botName: string
@@ -98,6 +103,12 @@ export interface BotInstanceResponse {
managers: ManagerResponse[]
nanny?: string
running: boolean
invites: BotSubredditInviteResponse[]
}
export interface SubredditOnboardingReadiness {
hasManager: boolean
isMod: boolean
}
export interface BotInstanceFunctions {
@@ -108,6 +119,7 @@ export interface BotInstanceFunctions {
getGuestSubreddits: (user: string) => string[]
canUserAccessBot: (user: string, subreddits: string[]) => boolean
canUserAccessSubreddit: (subreddit: string, user: string, subreddits: string[]) => boolean
getInvite(val: string): BotSubredditInviteResponse | undefined
}
export interface BotInstance extends BotInstanceResponse, BotInstanceFunctions {
@@ -162,3 +174,11 @@ export interface SubredditInviteData {
initialConfig?: string
expiresAt?: number | Dayjs
}
export interface HydratedSubredditInviteData extends Omit<SubredditInviteData, 'subreddit'>{
subreddit: string | Subreddit
}
export interface SubredditInviteDataPersisted extends SubredditInviteData {
id: string
}

View File

@@ -29,6 +29,7 @@ export const heartbeat = (opData: OperatorData) => {
guests: y.managerEntity.getGuests().map(x => guestEntityToApiGuest(x)),
})),
running: x.running,
invites: x.getSubredditInvites().map(y => ({subreddit: y.subreddit, id: y.id}))
})),
operators: opData.name,
operatorDisplay: opData.display,

View File

@@ -237,12 +237,7 @@ const saveGuestWikiEdit = async (req: Request, res: Response) => {
const {location, data, reason = 'Updated through CM Web', create = false} = req.body as any;
try {
// @ts-ignore
const wiki = await req.manager?.subreddit.getWikiPage(location) as WikiPage;
await wiki.edit({
text: data,
reason: `${reason} by Guest Mod ${req.user?.name}`,
});
await req.manager?.writeConfig(data, `${reason} by Guest Mod ${req.user?.name}`)
} catch (err: any) {
res.status(500);
return res.send(err.message);

View File

@@ -4,17 +4,61 @@ import {CMError} from "../../../../../Utils/Errors";
const getSubredditInvites = async (req: Request, res: Response) => {
return res.json(await req.serverBot.cacheManager.getPendingSubredditInvites());
return res.json(await req.serverBot.getSubredditInvites());
};
export const getSubredditInvitesRoute = [authUserCheck(), botRoute(), getSubredditInvites];
const getSubredditInvite = async (req: Request, res: Response) => {
const {id} = req.params;
const invite = await req.serverBot.getInvite(id);
if(invite !== undefined) {
const {bot, ...inviteRest} = invite;
const readiness = req.serverBot.getOnboardingReadiness(invite);
return res.json({...inviteRest, ...readiness});
}
return res.status(404);
};
export const getSubredditInviteRoute = [authUserCheck(['operator', 'machine']), botRoute(), getSubredditInvite];
const acceptSubredditInvite = async (req: Request, res: Response) => {
const {id} = req.params;
const invite = await req.serverBot.getInvite(id);
if(invite !== undefined) {
const {initialConfig, guests} = req.body as any;
invite.initialConfig = initialConfig;
invite.guests = guests;
try {
await req.serverBot.finishOnboarding(invite);
return res.status(200);
} catch(e: any) {
const errorParts = [e.message];
if(e instanceof CMError && e.cause !== undefined) {
errorParts.push(e.cause?.message);
}
res.status(500)
return res.send(e.message);
}
}
return res.status(404);
};
export const acceptSubredditInviteRoute = [authUserCheck(['operator', 'machine']), botRoute(), acceptSubredditInvite];
const addSubredditInvite = async (req: Request, res: Response) => {
const {subreddit} = req.body as any;
const {subreddit, initialConfig, guests} = req.body as any;
if (subreddit === undefined || subreddit === null || subreddit === '') {
return res.status(400).send('subreddit must be defined');
}
try {
await req.serverBot.cacheManager.addPendingSubredditInvite(subreddit);
const invite = await req.serverBot.addSubredditInvite({
subreddit,
initialConfig,
guests,
});
return res.status(200).send(invite.id);
} catch (e: any) {
if (e instanceof CMError) {
req.logger.warn(e);
@@ -24,16 +68,15 @@ const addSubredditInvite = async (req: Request, res: Response) => {
return res.status(500).send(e.message);
}
}
return res.status(200).send();
};
export const addSubredditInviteRoute = [authUserCheck(), botRoute(), addSubredditInvite];
const deleteSubredditInvite = async (req: Request, res: Response) => {
const {subreddit} = req.query as any;
const {subreddit, id} = req.query as any;
if (subreddit === undefined || subreddit === null || subreddit === '') {
return res.status(400).send('subreddit must be defined');
}
await req.serverBot.cacheManager.deletePendingSubredditInvite(subreddit);
await req.serverBot.deleteSubredditInvite(subreddit);
return res.status(200).send();
};
export const deleteSubredditInviteRoute = [authUserCheck(), botRoute(), deleteSubredditInvite];

View File

@@ -86,6 +86,24 @@ const generateDeltaResponse = (data: Record<string, any>, hash: string, response
// delta[k] = {new: newGuestItems, removed: removedGuestItems};
delta[k] = v;
break;
case 'subreddits':
// only used by opStats!
const refSubs = reference[k].map((x: any) => `${x.name}-${x.indicator}`);
const lastestSubs = v.map((x: any) => `${x.name}-${x.indicator}`);
if(symmetricalDifference(refSubs, lastestSubs).length === 0) {
continue;
}
const changedSubs = v.reduce((acc: any[], curr: any) => {
if(!reference[k].some((x: any) => x.name === curr.name && x.indicator === curr.indicator)) {
acc.push(curr);
}
return acc;
}, []);
delta[k] = changedSubs;
break
default:
if(!deepEqual(v, reference[k])) {
if(v !== null && typeof v === 'object' && reference[k] !== null && typeof reference[k] === 'object') {
@@ -104,6 +122,67 @@ const generateDeltaResponse = (data: Record<string, any>, hash: string, response
return resp;
}
export const opStatResponse = () => {
const middleware = [
authUserCheck(),
botRoute(false)
];
const response = async(req: Request, res: Response) =>
{
const responseType = req.query.type === 'delta' ? 'delta' : 'full';
let bots: Bot[] = [];
if(req.serverBot !== undefined) {
bots = [req.serverBot];
} else if(req.user !== undefined) {
bots = req.user.accessibleBots(req.botApp.bots);
}
const resp = [];
let index = 1;
for(const b of bots) {
resp.push({name: b.botName ?? `Bot ${index}`, data: {
status: b.running ? 'RUNNING' : 'NOT RUNNING',
indicator: b.running ? 'green' : 'red',
running: b.running,
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
error: b.error,
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
let indicator;
if (manager.managerState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
indicator = 'green';
} else if (manager.managerState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
indicator = 'red';
} else {
indicator = 'yellow';
}
return {
name: manager.displayLabel,
indicator,
};
}),
}});
index++;
}
const deltaResp = [];
for(const bResp of resp) {
const hash = `${req.user?.name}-opstats-${bResp.name}`;
const respData = generateDeltaResponse(bResp.data, hash, responseType);
if(Object.keys(respData).length !== 0) {
deltaResp.push({data: respData, name: bResp.name});
}
}
if(deltaResp.length === 0) {
return res.status(304).send();
}
return res.json(deltaResp);
}
return [...middleware, response];
}
const liveStats = () => {
const middleware = [
authUserCheck(),

View File

@@ -73,8 +73,10 @@ const logs = () => {
const requestedBots = bots.map(x => x.botName);
const origin = req.header('X-Forwarded-For') ?? req.header('host');
const stream = logger.stream();
try {
logger.stream().on('log', (log: LogInfo) => {
stream.on('log', (log: LogInfo) => {
if (isLogLineMinLevel(log, level as string)) {
const {subreddit: subName, bot, user} = log;
let canAccess = false;
@@ -105,13 +107,13 @@ const logs = () => {
logger.info(`${userName} from ${origin} => CONNECTED`);
await pEvent(req, 'close');
//logger.debug('Request closed detected with "close" listener');
res.destroy();
return;
} catch (e: any) {
if (e.code !== 'ECONNRESET') {
logger.error(e);
}
} finally {
stream.removeAllListeners();
logger.info(`${userName} from ${origin} => DISCONNECTED`);
res.destroy();
}

View File

@@ -13,7 +13,7 @@ import http from "http";
import {heartbeat} from "./routes/authenticated/applicationRoutes";
import logs from "./routes/authenticated/user/logs";
import status from './routes/authenticated/user/status';
import liveStats from './routes/authenticated/user/liveStats';
import liveStats, {opStatResponse} from './routes/authenticated/user/liveStats';
import {
actionedEventsRoute,
actionRoute, addGuestModRoute,
@@ -36,10 +36,12 @@ import { sleep } from '../../util';
import {Invokee} from "../../Common/Infrastructure/Atomic";
import {Point} from "@influxdata/influxdb-client";
import {
acceptSubredditInviteRoute,
addBotInviteRoute,
addSubredditInviteRoute,
deleteSubredditInviteRoute,
getBotInviteRoute,
getSubredditInviteRoute,
getSubredditInvitesRoute
} from "./routes/authenticated/user/invites";
@@ -161,41 +163,8 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
server.getAsync('/logs', ...logs());
server.getAsync('/stats', [authUserCheck(), botRoute(false)], async (req: Request, res: Response) => {
let bots: Bot[] = [];
if(req.serverBot !== undefined) {
bots = [req.serverBot];
} else if(req.user !== undefined) {
bots = req.user.accessibleBots(req.botApp.bots);
}
const resp = [];
let index = 1;
for(const b of bots) {
resp.push({name: b.botName ?? `Bot ${index}`, data: {
status: b.running ? 'RUNNING' : 'NOT RUNNING',
indicator: b.running ? 'green' : 'red',
running: b.running,
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
error: b.error,
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
let indicator;
if (manager.managerState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
indicator = 'green';
} else if (manager.managerState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
indicator = 'red';
} else {
indicator = 'yellow';
}
return {
name: manager.displayLabel,
indicator,
};
}),
}});
index++;
}
return res.json(resp);
});
server.getAsync('/stats', ...opStatResponse());
const passLogs = async (req: Request, res: Response, next: Function) => {
// @ts-ignore
req.sysLogs = sysLogs;
@@ -223,6 +192,10 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
server.getAsync('/bot/invite', ...getSubredditInvitesRoute);
server.getAsync('/bot/invite/:id', ...getSubredditInviteRoute);
server.postAsync('/bot/invite/:id', ...acceptSubredditInviteRoute);
server.postAsync('/bot/invite', ...addSubredditInviteRoute);
server.deleteAsync('/bot/invite', ...deleteSubredditInviteRoute);

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

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