Compare commits

..

72 Commits
0.5.0 ... 0.6.0

Author SHA1 Message Date
FoxxMD
ce4cb96d9a Merge branch 'edge' 2021-08-03 23:39:14 -04:00
FoxxMD
4457e3957d Implement json pretty print to html on config view 2021-08-03 23:38:48 -04:00
FoxxMD
c317f95953 Merge branch 'edge' 2021-08-03 22:43:02 -04:00
FoxxMD
2eda6c5fe1 Typo fix 2021-08-03 22:42:42 -04:00
FoxxMD
1108216a50 Improve formatting for cache section
* compact the calls/miss stats into one line
* combine cache type stats and description
* improve cache tips documentation
2021-08-03 21:22:03 -04:00
FoxxMD
b9215e944a Fix batch usernote save logic
* timeout returns no argument but we have bound the function so just reassign this to self
* fix errenous save left over from previous changes
* keep track of number of usernotes to save and log to debug
* log to debug when executing save immediately due to cache miss
2021-08-03 15:08:02 -04:00
FoxxMD
a976171e3a Merge branch 'develop' into batchUserNotes
# Conflicts:
#	src/Subreddit/UserNotes.ts
2021-08-03 14:36:20 -04:00
FoxxMD
b773afbe38 Add comment check stats to cache breakdown 2021-08-03 14:29:12 -04:00
FoxxMD
045e2c1d33 Fix check cache result behavior
* Don't set result in cache if it already exists
* Fix resource manager init issue with cache manager?
* Make check cache logging statements clearer
2021-08-03 14:25:11 -04:00
FoxxMD
ad45f75267 More caching improvements
* Refactor cache-manager creation to use built in "none" store to simplify cache usage
* Implement comment check cache result flow
2021-08-03 13:06:34 -04:00
FoxxMD
643790d3bd Add 'enable' parameter for actions config 2021-08-03 10:54:50 -04:00
FoxxMD
a531d7e4e0 Add submission state for link flair text/css 2021-08-03 10:01:05 -04:00
FoxxMD
be065f919c Implement POC batch usernotes save flow 2021-08-02 21:23:04 -04:00
FoxxMD
8d5d44bf0d Update schema 2021-08-02 16:46:49 -04:00
FoxxMD
bbd8a6633e Implement check enabled state
To make it easier to turn on/off a check without having to comment out the entire thing in config
2021-08-02 16:46:07 -04:00
FoxxMD
038e5d086b Fix missing check on optimization 2021-08-02 16:05:40 -04:00
FoxxMD
5422b181c0 Optimize isItem test when only testing comment's submission state 2021-08-02 16:01:30 -04:00
FoxxMD
d0e0515990 Merge branch 'edge' 2021-08-02 15:44:57 -04:00
FoxxMD
931dfa67fd Display more cache info in web interface
* Show total miss and percent
* Move breakdown into tooltip
* Show item crit, submission, and comment cache stats in breakdown
2021-08-02 15:44:25 -04:00
FoxxMD
af1ea5543e Implement caching for more components
* Implement caching for specific activities
* Implement/refactor item is criteria to cache activities and results
2021-08-02 15:10:49 -04:00
FoxxMD
fd7a6edeb6 Fix activity window criteria checks for 'any' condition
Should check time before count
2021-08-02 09:19:02 -04:00
FoxxMD
0a3409cfef Add title matching on SubmissionState filter 2021-08-01 22:07:19 -04:00
FoxxMD
89b2932495 Missed some slashes 2021-07-31 23:27:47 -04:00
FoxxMD
3a05e43ce9 Clearer error message on wiki content failed response 2021-07-31 23:08:32 -04:00
FoxxMD
8b1d3cb170 Implement MessageAction
* enable bot to send private messages as self or modmail
* add necessary permissions to oauth helper to make this possible
* update schema with message action structure
2021-07-31 15:05:49 -04:00
FoxxMD
90df5f45a8 Add parent submission state testing when checking a comment activity 2021-07-31 14:22:17 -04:00
FoxxMD
ba4b4a69a7 Better formatting for downloaded wiki content 2021-07-31 12:35:08 -04:00
FoxxMD
e3d4ffa36d Disable cloning on cache
* Lodash has issues iterating over properties because the items can be a `proxy`
* We're not modifying the items at any point anyway so cloning to preserve state isn't necessary
* We want to get proxy objects back (potentially) since we'll still be able to use them for retrieving more data later
2021-07-31 12:29:06 -04:00
FoxxMD
cdddd8de48 Merge branch 'edge' 2021-07-30 18:17:38 -04:00
FoxxMD
7f1429395c Fix links in getting started 2021-07-30 18:17:13 -04:00
FoxxMD
f598215d88 Merge branch 'edge' 2021-07-30 14:46:51 -04:00
FoxxMD
c92e6775cb Add config download option 2021-07-30 14:38:12 -04:00
FoxxMD
2a5f812dba Implement defining multiple operators 2021-07-30 14:19:49 -04:00
FoxxMD
54905da782 Implement extended memory store to allow pruning 2021-07-30 13:48:58 -04:00
FoxxMD
5f30dd8ce9 Refactor caching interface to simplify
No need to have it be optional string provider and it unnecessarily complicates json schema/configuration
2021-07-30 12:37:07 -04:00
FoxxMD
547f57b99f Some configuration override clarification 2021-07-30 12:05:07 -04:00
FoxxMD
bf336ca55a Move the schema/settings for operator into json schema (viewer)
easier to read, has full examples, and validation
2021-07-30 12:00:42 -04:00
FoxxMD
4716ac8c0a Add docs for thresholds and expand caching docs 2021-07-30 10:43:05 -04:00
FoxxMD
79a518edbc Move the getting started section up so its more visible 2021-07-30 10:06:09 -04:00
FoxxMD
b72a3fea7f Fill out some TODOs for docs (checks, filters) 2021-07-30 10:04:51 -04:00
FoxxMD
58603f17f4 Mention the schema editor in more places 2021-07-30 10:04:49 -04:00
FoxxMD
99b5a01835 Clean up subreddit-ready examples 2021-07-30 10:04:40 -04:00
FoxxMD
fd41c23128 Add subreddit-ready examples #14 2021-07-29 17:00:00 -04:00
FoxxMD
3230c4b30b Update repeat activity to be activity agnostic 2021-07-29 16:46:41 -04:00
FoxxMD
38507c8990 Fix anchor 2021-07-29 15:21:13 -04:00
FoxxMD
136098354b Improve getting started documentation #14 2021-07-29 15:19:56 -04:00
FoxxMD
29fc9a3a2d Add maxWorker to operator config docs 2021-07-29 13:39:58 -04:00
FoxxMD
0c7218571c Merge branch 'edge' 2021-07-29 13:25:16 -04:00
FoxxMD
fd4c2a38e7 Implement max queue workers as configurable
Globally or per-subreddit (max global)
2021-07-29 13:15:32 -04:00
FoxxMD
f89dca5d77 Use botname in more places
* Add operator option for naming the bot, otherwise default to authenticated account name
* Use botname as discord webhook name
2021-07-29 12:02:36 -04:00
FoxxMD
acc7c49e0e Merge branch 'edge' 2021-07-29 11:27:42 -04:00
FoxxMD
7175965e3d Update missed naming changes 2021-07-29 11:26:26 -04:00
FoxxMD
3ec7d3530d Update actions to not trigger on documentation updates 2021-07-29 11:23:14 -04:00
FoxxMD
01839512d5 Merge branch 'edge' 2021-07-29 11:14:33 -04:00
FoxxMD
d37958e5c8 Update links and branding in web server pages 2021-07-29 11:14:09 -04:00
FoxxMD
bfbbb3466a Update footer link 2021-07-29 11:01:11 -04:00
FoxxMD
775613374b Update dockerhub image tagging
* Update repository to new naming
* Use 'edge' instead of 'develop'
2021-07-29 10:51:43 -04:00
FoxxMD
44c8bd9a6a Rebrand to conform to reddit 3rd party app naming guidelines
They don't like having reddit as part of the name unless its "for reddit" -- so just remove reddit from name altogether and also drop "bot" as its superfluous. Shorter name is better anyway
2021-07-29 10:50:58 -04:00
FoxxMD
45e61b8bc7 Update tooltips 2021-07-29 10:29:20 -04:00
FoxxMD
4680640b0c Merge branch 'develop' 2021-07-28 16:58:36 -04:00
FoxxMD
897802b234 Update npm start command 2021-07-28 16:04:49 -04:00
FoxxMD
82b353c6d9 Update package-lock 2021-07-28 15:37:59 -04:00
FoxxMD
254d2ca896 Add local install requirements 2021-07-28 15:37:44 -04:00
FoxxMD
5a531f0122 Remove remaining js/map files from src 2021-07-28 13:57:54 -04:00
FoxxMD
0afd87ab1b Doc improvements
* Reorganize some main readme contents into separate docs
* Add full bot authentication guide
* Add screenshots and web interface section

Some link fixes and clarifications

Fix another link

Fix another link

More doc cleanup

More doc cleanup

More doc cleanup

Add link to docs in main readme summary
2021-07-28 13:47:24 -04:00
FoxxMD
c1ab3b11f4 Ignore github folder for docker build 2021-07-28 11:31:44 -04:00
Matt Foxx
222fe0aeac Create dockerhub.yml
(cherry picked from commit b813ebdd96)
2021-07-28 11:31:26 -04:00
FoxxMD
ceb98d04bb Update application version 2021-07-28 11:30:04 -04:00
Matt Foxx
b813ebdd96 Create dockerhub.yml 2021-07-28 11:27:04 -04:00
FoxxMD
4865259ae8 Improve app exit notifications and fix wiki location
* Refactor server so app is passed back to main index.js so we can handle SIGTERM in a central location and determine if exit was based on uncaught error or not
* Fix missing assignment of default wikiLocation to manager
* await discord notifier so on app exit the notification is actually sent before exit
2021-07-28 10:45:12 -04:00
FoxxMD
2616439f5f Better formatting for check triggered notification 2021-07-28 09:35:17 -04:00
FoxxMD
0eddac35fa Fix per subreddit cache request rate display 2021-07-28 09:33:36 -04:00
80 changed files with 4570 additions and 1953 deletions

View File

@@ -5,3 +5,4 @@ Dockerfile
.git
src/logs
/docs
.github

51
.github/workflows/dockerhub.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
name: Publish Docker image to Dockerhub
on:
push:
branches:
- 'master'
- 'edge'
tags:
- 'v*.*.*'
# don't trigger if just updating docs
paths-ignore:
- '**.md'
jobs:
push_to_registry:
name: Push Docker image to Docker Hub
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@f054a8b539a109f9f41c372932f1ae047eff08c9
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Extract metadata (tags, labels) for Docker
id: meta
uses: docker/metadata-action@v3
with:
images: foxxmd/context-mod
# generate Docker tags based on the following events/attributes
tags: |
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
flavor: |
latest=false
- name: Build and push Docker image
uses: docker/build-push-action@v2
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}

234
README.md
View File

@@ -1,10 +1,8 @@
# reddit-context-bot
[![Latest Release](https://img.shields.io/github/v/release/foxxmd/reddit-context-bot)](https://github.com/FoxxMD/reddit-context-bot/releases)
[![Latest Release](https://img.shields.io/github/v/release/foxxmd/context-mod)](https://github.com/FoxxMD/context-mod/releases)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/reddit-context-bot)](https://hub.docker.com/r/foxxmd/reddit-context-bot)
[![Docker Pulls](https://img.shields.io/docker/pulls/foxxmd/context-mod)](https://hub.docker.com/r/foxxmd/context-mod)
**Context Bot** is an event-based, [reddit](https://reddit.com) moderation bot built on top of [snoowrap](https://github.com/not-an-aardvark/snoowrap) and written in [typescript](https://www.typescriptlang.org/).
**Context Mod** (CM) is an event-based, [reddit](https://reddit.com) moderation bot built on top of [snoowrap](https://github.com/not-an-aardvark/snoowrap) and written in [typescript](https://www.typescriptlang.org/).
It is designed to help fill in the gaps for [automoderator](https://www.reddit.com/wiki/automoderator/full-documentation) in regard to more complex behavior with a focus on **user-history based moderation.**
@@ -17,29 +15,32 @@ An example of the above that Context Bot can do now:
Some feature highlights:
* Simple rule-action behavior can be combined to create any level of complexity in behavior
* One instance can handle managing many subreddits (as many as it has moderator permissions in!)
* Per-subreddit configuration is handled by JSON stored in the subreddit wiki
* Any text-based actions (comment, submission, message, usernotes, etc...) can be configured via a wiki page or raw text in JSON
* All text-based actions support [mustache](https://mustache.github.io) templating
* One instance can manage all moderated subreddits for the authenticated account
* **Per-subreddit configuration** is handled by JSON stored in the subreddit wiki
* Any text-based actions (comment, submission, message, usernotes, ban, etc...) can be configured via a wiki page or raw text in JSON and support [mustache](https://mustache.github.io) [templating](/docs/actionTemplating.md)
* History-based rules support multiple "valid window" types -- [ISO 8601 Durations](https://en.wikipedia.org/wiki/ISO_8601#Durations), [Day.js Durations](https://day.js.org/docs/en/durations/creating), and submission/comment count limits.
* Checks/Rules support skipping behavior based on:
* author criteria (name, css flair/text, moderator status, and [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes))
* Support Activity skipping based on:
* Author criteria (name, css flair/text, age, karma, moderator status, and [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes))
* Activity state (removed, locked, distinguished, etc.)
* Rules and Actions support named references so you write rules/actions once and reference them anywhere
* User-configurable global/subreddit-level API caching with optional redis-backend
* Rules and Actions support named references (write once, reference anywhere)
* Global/subreddit-level **API caching**
* Support for [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) as criteria or Actions (writing notes)
* Docker container support
* Event notification via Discord
* **Web interface** for monitoring and administration
# Table of Contents
* [How It Works](#how-it-works)
* [Installation](#installation)
* [Configuration And Docs](#configuration)
* [Usage](#usage)
* [Getting Started](#getting-started)
* [Configuration And Documentation](#configuration-and-documentation)
* [Web UI and Screenshots](#web-ui-and-screenshots)
### How It Works
Context Bot's configuration is made up of a list of **Checks**. Each **Check** consists of :
Each subreddit using the RCB bot configures its behavior via their own wiki page.
When a monitored **Event** (new comment/submission, new modqueue item, etc.) is detected the bot runs through a list of **Checks** to determine what to do with the **Activity** from that Event. Each **Check** consists of :
#### Kind
@@ -47,202 +48,63 @@ Is this check for a submission or comment?
#### Rules
A list of **Rule** objects to run against the activity. Triggered Rules can cause the whole Check to trigger and run its **Actions**
A list of **Rule** objects to run against the **Activity**. Triggered Rules can cause the whole Check to trigger and run its **Actions**
#### Actions
A list of **Action** objects that describe what the bot should do with the activity or author of the activity. The bot will run **all** Actions in this list.
A list of **Action** objects that describe what the bot should do with the **Activity** or **Author** of the activity (comment, remove, approve, etc.). The bot will run **all** Actions in this list.
___
The **Checks** for a subreddit are split up into **Submission Checks** and **Comment Checks** based on their **kind**. Each list of checks is run independently based on when events happen (submission or comment).
When an event occurs all Checks of that type are run in the order they were listed in the configuration. When one check is triggered (an action is performed) the remaining checks will not be run.
When an Event occurs all Checks of that type are run in the order they were listed in the configuration. When one check is triggered (an Action is performed) the remaining checks will not be run.
## Installation
___
[Learn more about the RCB lifecycle and core concepts in the docs.](/docs#how-it-works)
### Locally
## Getting Started
Clone this repository somewhere and then install from the working directory
#### Operators
```bash
git clone https://github.com/FoxxMD/reddit-context-bot.git .
cd reddit-context-bot
npm install
```
This guide is for users who want to **run their own bot on a ContextMod instance.**
### [Docker](https://hub.docker.com/r/foxxmd/reddit-context-bot)
See the [Operator's Getting Started Guide](/docs/gettingStartedOperator.md)
```
foxxmd/reddit-context-bot:latest
```
#### Moderators
Adding [**environmental variables**](#usage) to your `docker run` command will pass them through to the app EX:
```
docker run -e "CLIENT_ID=myId" ... foxxmd/reddit-context-bot
```
This guide is for **reddit moderators** who want to configure an existing CM bot to run on their subreddit.
### [Heroku Quick Deploy](https://heroku.com/about)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https://github.com/FoxxMD/reddit-context-bot)
See the [Moderator's Getting Started Guide](/docs/gettingStartedMod.md)
## Configuration and Documentation
## Configuration
Context Bot's configuration can be written in JSON, [JSON5](https://json5.org/) or YAML. Its schema conforms to [JSON Schema Draft 7](https://json-schema.org/). Additionally, many **operator** settings can be passed via command line or environmental variables.
[**Check the docs for in-depth explanations of all concepts and examples**](/docs)
* For **operators** (running the bot instance) see the [Operator Configuration](/docs/operatorConfiguration.md) guide
* For **moderators** consult the [app schema and examples folder](/docs/#configuration-and-usage)
Context Bot's configuration can be written in JSON, [JSON5](https://json5.org/) or YAML. It's [schema](/src/Schema/App.json) conforms to [JSON Schema Draft 7](https://json-schema.org/).
[**Check the full docs for in-depth explanations of all concepts and examples**](/docs)
I suggest using [Atlassian JSON Schema Viewer](https://json-schema.app/start) ([direct link](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)) so you can view all documentation while also interactively writing and validating your config! From there you can drill down into any object, see its requirements, view an example JSON document, and live-edit your configuration on the right-hand side.
## Web UI and Screenshots
### Action Templating
RCB comes equipped with a web interface designed for use by both moderators and bot operators. Some feature highlights:
Actions that can submit text (Report, Comment) 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.
* Authentication via Reddit OAuth -- only accessible if you are the bot operator or a moderator of a subreddit the bot moderates
* Monitor API usage/rates
* Monitoring and administration **per subreddit:**
* Start/stop/pause various bot components
* View statistics on bot usage (# of events, checks run, actions performed) and cache usage
* View various parts of your subreddit's configuration and manually update configuration
* View **real-time logs** of what the bot is doing on your subreddit
* **Run bot on any permalink**
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.
![Subreddit View](docs/screenshots/subredditStatus.jpg)
All Actions with `content` have access to this data:
Additionally, a helper webpage is available to help initial setup of your bot with reddit's oauth authentication. [Learn more about using the oauth helper.](docs/botAuthentication.md#cm-oauth-helper-recommended)
```json5
{
item: {
kind: 'string', // the type of item (comment/submission)
author: 'string', // name of the item author (reddit user)
permalink: 'string', // a url to the item
url: 'string', // if the item is a Submission then its URL (external for link type submission, reddit link for self-posts)
title: 'string', // if the item is a Submission, then the title of the Submission,
botLink: 'string' // a link to the bot's FAQ
},
rules: {
// contains all rules that were run and are accessible using the name, lowercased, with all spaces/dashes/underscores removed
}
}
```
The properties of `rules` are accessible using the name, lower-cased, with all spaces/dashes/underscores. If no name is given `kind` is used as `name` Example:
```
"rules": [
{
"name": "My Custom-Recent Activity Rule", // mycustomrecentactivityrule
"kind": "recentActivity"
},
{
// name = repeatsubmission
"kind": "repeatActivity",
}
]
```
**To see what data is available for individual Rules [consult the schema](#configuration) for each Rule.**
#### Quick Templating Tutorial
<details>
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:
```
myVariable = 50;
myOtherVariable = "a text fragment"
template = "This is my template, the variable is {{myVariable}}, my other variable is {{myOtherVariable}}, and that's it!";
console.log(Mustache.render(template, {myVariable});
// will render...
"This is my template, the variable is 50, my other variable is a text fragment, and that's it!";
```
**Note: When accessing an object or its properties you must use dot notation**
```
const item = {
aProperty: 'something',
anotherObject: {
bProperty: 'something else'
}
}
const content = "My content will render the property {{item.aProperty}} like this, and another nested property {{item.anotherObject.bProperty}} like this."
```
</details>
## Usage
```
Usage: index [options] [command]
Options:
-h, --help display help for command
Commands:
run [options] [interface] Monitor new activities from configured subreddits.
check [options] <activityIdentifier> [type] Run check(s) on a specific activity
unmoderated [options] <subreddits...> Run checks on all unmoderated activity in the modqueue
help [command] display help for command
Options:
-c, --operatorConfig <path> An absolute path to a JSON file to load all parameters from (default: process.env.OPERATOR_CONFIG)
-i, --clientId <id> Client ID for your Reddit application (default: process.env.CLIENT_ID)
-e, --clientSecret <secret> Client Secret for your Reddit application (default: process.env.CLIENT_SECRET)
-a, --accessToken <token> Access token retrieved from authenticating an account with your Reddit Application (default: process.env.ACCESS_TOKEN)
-r, --refreshToken <token> Refresh token retrieved from authenticating an account with your Reddit Application (default: process.env.REFRESH_TOKEN)
-u, --redirectUri <uri> Redirect URI for your Reddit application (default: process.env.REDIRECT_URI)
-t, --sessionSecret <secret> Secret use to encrypt session id/data (default: process.env.SESSION_SECRET || a random string)
-s, --subreddits <list...> List of subreddits to run on. Bot will run on all subs it has access to if not defined (default: process.env.SUBREDDITS)
-d, --logDir [dir] Absolute path to directory to store rotated logs in. Leaving undefined disables rotating logs (default: process.env.LOG_DIR)
-l, --logLevel <level> Minimum level to log at (default: process.env.LOG_LEVEL || verbose)
-w, --wikiConfig <path> Relative url to contextbot wiki page EX https://reddit.com/r/subreddit/wiki/<path> (default: process.env.WIKI_CONFIG || 'botconfig/contextbot')
--snooDebug Set Snoowrap to debug. If undefined will be on if logLevel='debug' (default: process.env.SNOO_DEBUG)
--authorTTL <ms> Set the TTL (ms) for the Author Activities shared cache (default: process.env.AUTHOR_TTL || 60000)
--heartbeat <s> Interval, in seconds, between heartbeat checks. (default: process.env.HEARTBEAT || 300)
--softLimit <limit> When API limit remaining (600/10min) is lower than this subreddits will have SLOW MODE enabled (default: process.env.SOFT_LIMIT || 250)
--hardLimit <limit> When API limit remaining (600/10min) is lower than this all subreddit polling will be paused until api limit reset (default: process.env.SOFT_LIMIT || 250)
--dryRun Set all subreddits in dry run mode, overriding configurations (default: process.env.DRYRUN || false)
--proxy <proxyEndpoint> Proxy Snoowrap requests through this endpoint (default: process.env.PROXY)
--operator <name> Username of the reddit user operating this application, used for displaying OP level info/actions in UI (default: process.env.OPERATOR)
--operatorDisplay <name> An optional name to display who is operating this application in the UI (default: process.env.OPERATOR_DISPLAY || Anonymous)
-p, --port <port> Port for web server to listen on (default: process.env.PORT || 8085)
-q, --shareMod If enabled then all subreddits using the default settings to poll "unmoderated" or "modqueue" will retrieve results from a shared request to /r/mod (default: process.env.SHARE_MOD || false)
-h, --help display help for command
```
### Logging
### Reddit App??
To use this bot you must do two things:
* Create a reddit application
* Authenticate that application to act as a user (login to the application with an account)
#### Create Application
Visit [your reddit preferences](https://www.reddit.com/prefs/apps) and at the bottom of the page go through the **create an(other) app** process.
* Choose **script**
* For redirect uri use https://not-an-aardvark.github.io/reddit-oauth-helper/
* Write down your **Client ID** and **Client Secret** somewhere
#### Authenticate an Account
Visit https://not-an-aardvark.github.io/reddit-oauth-helper/
* Input your **Client ID** and **Client Secret** in the text boxes with those names.
* Choose scopes. **It is very important you check everything on this list or Context Bot will not work correctly**
* edit
* flair
* history
* identity
* modcontributors
* modflair
* modposts
* modself
* mysubreddits
* read
* report
* submit
* wikiread
* wikiedit (if you are using Toolbox User Notes)
* Click **Generate tokens**, you will get a popup asking you to approve access (or login) -- **the account you approve access with is the account that Bot will control.**
* After approving an **Access Token** and **Refresh Token** will be shown at the bottom of the page. Write these down.
You should now have all the information you need to start the bot.
![Oauth View](docs/screenshots/oauth.jpg)
## License

View File

@@ -1,7 +1,7 @@
{
"name": "Reddit Context Bot",
"description": "An event-based, reddit moderation bot built on top of snoowrap and written in typescript",
"repository": "https://github.com/FoxxMD/reddit-context-bot",
"repository": "https://github.com/FoxxMD/context-mod",
"stack": "container",
"env": {
"CLIENT_ID": {

View File

@@ -5,6 +5,7 @@
* [Getting Started](#getting-started)
* [How It Works](#how-it-works)
* [Concepts](#concepts)
* [Check](#checks)
* [Rule](#rule)
* [Examples](#available-rules)
* [Rule Set](#rule-set)
@@ -12,29 +13,35 @@
* [Action](#action)
* [Examples](#available-actions)
* [Filters](#filters)
* [Configuration](#configuration)
* [Configuration and Usage](#configuration-and-usage)
* [Common Resources](#common-resources)
* [Activities `window`](#activities-window)
* [Comparisons](#thresholds-and-comparisons)
* [Activity Templating](/docs/actionTemplating.md)
* [Best Practices](#best-practices)
* [Subreddit-ready Configurations](#subreddit-ready-configurations)
* [Named Rules](#named-rules)
* [Rule Order](#rule-order)
* [Caching](#caching)
* FAQ
## Getting Started
Review **at least** the **How It Works** and **Concepts** below and then head to the [**Getting Started documentation.**](/docs/gettingStarted.md)
Review **at least** the **How It Works** and **Concepts** below, then:
* For **Operators** (running a bot instance) refer to [**Operator Getting Started**](/docs/gettingStartedOperator.md) guide
* For **Moderators** (configuring an existing bot for your subreddit) refer to the [**Moderator Getting Started**](/docs/gettingStartedMod.md) guide
## How It Works
Where possible Reddit Context Bot (RCB) uses the same terminology as, and emulates the behavior, of **automoderator** so if you are familiar with that much of this may seem familiar to you.
Where possible Context Mod (CM) uses the same terminology as, and emulates the behavior, of **automoderator** so if you are familiar with that much of this may seem familiar to you.
RCB's lifecycle looks like this:
CM's lifecycle looks like this:
#### 1) A new event in your subreddit is received by RCB
#### 1) A new event in your subreddit is received by CM
The events RCB watches for are configured by you. These can be new modqueue items, submissions, or comments.
The events CM watches for are configured by you. These can be new modqueue/unmoderated items, submissions, or comments.
#### 2) RCB sequentially processes each Check in your configuration
#### 2) CM sequentially processes each Check in your configuration
A **Check** is a set of:
@@ -47,16 +54,32 @@ Once a Check is **triggered** no more Checks will be processed. This means all s
#### 4) All Actions from that Check are executed
After all Actions are executed RCB returns to waiting for the next Event.
After all Actions are executed CM returns to waiting for the next Event.
## Concepts
Core, high-level concepts regarding how RCB works.
Core, high-level concepts regarding how CM works.
### Checks
TODO
A **Check** is the main logical unit of behavior for the bot. It is equivalent to "if X then Y". A Check is comprised of:
* One or more **Rules** that are tested against an **Activity**
* One of more **Actions** that are performed when the **Rules** are satisfied
The bot's configuration can be made up of one or more **Checks** that are processed **in the order they are listed in the configuration.**
Once a Check is **triggered** (its Rules are satisfied and Actions performed) all subsequent Checks are skipped.
Some other important concepts regarding Checks:
* All Checks have a **kind** (defined in the configuration) that determine if they should run on **Submissions** or **Comments**
* Checks have a **condition** property that determines when they are considered **triggered**
* If the **condition** is `AND` then ALL of their **Rules** must be **triggered** for the Check to be **triggered**
* If the **condition** is `OR` then if ANY **Rules** is triggered **triggered** then the Check is **triggered**
Examples of different types of Checks can be found in the [subreddit-ready examples.](/docs/examples/subredditReady)
### Rule
A **Rule** is some set of **criteria** (conditions) that are tested against an Activity (comment/submission), a User, or a User's history. A Rule is considered **triggered** when the **criteria** for that rule are found to be **true** for whatever is being tested against.
@@ -67,7 +90,7 @@ There are generally three main properties for a Rule:
* **Activities Window** -- If applicable, the range of activities that the **criteria** will be tested against.
* **Rule-specific options** -- Any number of options that modify how the **criteria** are tested.
RCB has different **Rules** that can test against different types of behavior and aspects of a User, their history, and the Activity (submission/common) being checked.
CM has different **Rules** that can test against different types of behavior and aspects of a User, their history, and the Activity (submission/common) being checked.
#### Available Rules
Find detailed descriptions of all the Rules, with examples, below:
@@ -77,6 +100,7 @@ Find detailed descriptions of all the Rules, with examples, below:
* [Repeat Activity](/docs/examples/repeatActivity)
* [History](/docs/examples/history)
* [Author](/docs/examples/author)
* Regex
### Rule Set
@@ -108,9 +132,9 @@ Example
### Action
An **Action** is some action the bot can take against the checked Activity (comment/submission) or Author of the Activity. RCB has Actions for most things a normal reddit user or moderator can do.
An **Action** is some action the bot can take against the checked Activity (comment/submission) or Author of the Activity. CM has Actions for most things a normal reddit user or moderator can do.
### Available Actions
#### Available Actions
* Remove (Comment/Submission)
* Flair (Submission)
@@ -121,20 +145,74 @@ An **Action** is some action the bot can take against the checked Activity (comm
* Report (Comment/Submission)
* [UserNote](/docs/examples/userNotes) (User, when /r/Toolbox is used)
For detailed explanation and options of what individual Actions can do [see the links in the `actions` property in the schema.](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
For detailed explanation and options of what individual Actions can do [see the links in the `actions` property in the schema.](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json)
### Filters
TODO
**Checks, Rules, and Actions** all have two additional (optional) criteria "tests". These tests behave differently than rule/check triggers in that:
## Configuration
* When they **pass** the thing being tested continues to process as usual
* When they **fail** the thing being tested **is skipped, not failed.**
For **Checks** and **Actions** skipping means that the thing is not processed. The Action is not run, the Check is not triggered.
In the context of **Rules** (in a Check) skipping means the Rule does not get run BUT it does not fail. The Check will continue processing as if the Rule did not exist. However, if ALL Rules in a Check are skipped then the Check does "fail" (is not triggered).
#### Available Filters
##### Item Filter (`itemIs`)
This filter will test against the **state of the Activity currently being run.** Some criteria available to test against IE "Is the activity...":
* removed
* nsfw
* locked
* stickied
* deleted
* etc...
The `itemIs` filter is made up of an array (list) of `State` criteria objects. **All** criteria in the array must pass for this filter to pass.
There are two different State criteria depending on what type of Activity is being tested:
* Submission -- [SubmissionState](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FSubmissionState?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json)
* Comment -- [CommentState](https://json-schema.app/view/%23/%23%2Fdefinitions%2FCommentCheckJson/%23%2Fdefinitions%2FCommentState?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json)
##### Author Filter (`authorIs`)
This filter will test against the **Author of the Activity currently being run.** Some criteria available to test against:
* account age
* comment, link, and total karma
* subreddit flair text/css
* name
* User Notes
* verified
* etc...
The `authorIs` filter is made up two (optional) lists of [`AuthorCriteria`](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FAuthorOptions/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) criteria objects that define how the test behaves:
* `include` list -- If **any** `AuthorCriteria` from this list passes then the `authorIs` test passes
* `exclude` list -- If **any** `AuthorCriteria` from this list **does not pass** then the `authorIs` test passes. **Note:** This property is ignored if `include` is also present IE you cannot use both properties at the same time.
Refer to the [app schema for `AuthorCriteria`](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FAuthorOptions/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for all available properties to test against.
Some examples of using `authorIs` can be found in the [Author examples.](/docs/examples/author)
## Configuration And Usage
* For **Operator/Bot maintainers** see **[Operation Configuration](/docs/operatorConfiguration.md)**
* For **Moderators** see the [App Schema](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) and [examples](/docs/examples)
* [CLI Usage](docs/operatorConfiguration.md#cli-usage)
* For **Moderators**
* Refer to the [examples folder](/docs/examples) or the [subreddit-ready examples](/docs/examples/subredditReady)
* as well as the [schema editor](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) which has
* fully annotated configuration data/structure
* generated examples in json/yaml
* built-in editor that automatically validates your config
## Common Resources
Technical information on recurring, common data/patterns used in RCB.
Technical information on recurring, common data/patterns used in CM.
### Activities `window`
@@ -144,7 +222,51 @@ Refer to the [Activities Window](/docs/activitiesWindow.md) documentation for a
### Thresholds and Comparisons
TODO
Most rules/filters have criteria that require you to define a specific condition to test against. This can be anything from repeats of activities to account age.
In all of these scenarios the condition is defined using a subset of [comparison operators](https://www.codecademy.com/articles/fwd-js-comparison-logical) (very similar to how automoderator does things).
Available operators:
* `<` -- **less than** => `5 < 6` => 5 is less than 6
* `>` -- **greater than** => `6 > 5` => 6 is greater than 5
* `<=` -- **less than or equal to** => `5 <= 5` => 5 is less than **or equal to** 5
* `>=` -- **greater than or equal to** => `5 >= 5` => 5 is greater than **or equal to** 5
In the context of a rule/filter comparison you provide the comparison **omitting** the value that is being tested. An example...
The RepeatActivity rule has a `threshold` comparison to test against the number of repeat activities it finds
* You want the rule to trigger if it finds **4 or more repeat activities**
* The rule would be configured like this `"threshold": ">= 4"`
Essentially what this is telling the rule is `threshold: "x >= 4"` where `x` is the largest repeat of activities it finds.
#### Other Comparison Types
Other than comparison numeric values there are two other values that can be compared (depending on the criteria)
##### Percentages
Some criteria accept an optional **percentage** to compare against:
```
"threshold": "> 20%"
```
Refer to the individual rule/criteria schema to see what this percentage is comparing against.
##### Durations
Some criteria accept an optional **duration** to compare against:
```
"threshold": "< 1 month"
```
The duration value compares a time range from **now** to `duration value` time in the past.
Refer to [duration values in activity window documentation](/docs/activitiesWindow.md#duration-values) as well as the individual rule/criteria schema to see what this duration is comparing against.
## Best Practices
@@ -152,7 +274,7 @@ TODO
All **Rules** in a subreddit's configuration can be assigned a **name** that can then be referenced from any other Check.
Create general-use rules so they can be reused and de-clutter your configuration. Additionally RCB will automatically cache the result of a rule so there is a performance and api usage benefit to re-using Rules.
Create general-use rules so they can be reused and de-clutter your configuration. Additionally, CM will automatically cache the result of a rule so there is a performance and api usage benefit to re-using Rules.
See [ruleNameReuse.json5](/docs/examples/advancedConcepts/ruleNameReuse.json5) for a detailed configuration with annotations.
@@ -189,22 +311,26 @@ If the Check is using `AND` condition for its rules (default) then if either Rul
**It is therefore advantageous to list your lightweight Rules first in each Check.**
### API Caching
### Caching
Context bot implements some basic caching functionality for **Author Activities** and wiki pages (on Comment/Report Actions).
ContextMod implements caching functionality for:
**Author Activities** are cached for a subreddit-configurable amount of time (10 seconds by default). A cached activities set can be re-used if the **window on a Rule is identical to the window on another Rule**.
* author history (`window` criteria in rules)
* `authorIs` results
* `content` that uses wiki pages (on Comment/Report/Ban Actions)
* and User Notes
This means that when possible you should re-use window values.
All of these use api requests so caching them reduces api usage.
IE If you want to check an Author's Activities for a time range try to always use **7 Days** or always use **50 Items** for absolute counts.
Cached results can be re-used if the criteria in configuration is identical to a previously cached result. So...
* author history cache results are re-used if **`window` criteria on a Rule is identical to the `window` on another Rule** IE always use **7 Days** or always use **50 Items** for absolute counts.
* `authorIs` criteria is identical to another `authorIs` elsewhere in configuration..
* etc...
Re-use will result in less API calls and faster Check times.
## Subreddit-ready Configurations
TODO
PROTIP: You can monitor the re-use of cache in the `Cache` section of your subreddit on the web interface. See the tooltips in that section for a better breakdown of cache statistics.
## FAQ

72
docs/actionTemplating.md Normal file
View File

@@ -0,0 +1,72 @@
Actions that can submit text (Report, Comment) 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.
All Actions with `content` have access to this data:
```json5
{
item: {
kind: 'string', // the type of item (comment/submission)
author: 'string', // name of the item author (reddit user)
permalink: 'string', // a url to the item
url: 'string', // if the item is a Submission then its URL (external for link type submission, reddit link for self-posts)
title: 'string', // if the item is a Submission, then the title of the Submission,
botLink: 'string' // a link to the bot's FAQ
},
rules: {
// contains all rules that were run and are accessible using the name, lowercased, with all spaces/dashes/underscores removed
}
}
```
The properties of `rules` are accessible using the name, lower-cased, with all spaces/dashes/underscores. If no name is given `kind` is used as `name` Example:
```
"rules": [
{
"name": "My Custom-Recent Activity Rule", // mycustomrecentactivityrule
"kind": "recentActivity"
},
{
// name = repeatsubmission
"kind": "repeatActivity",
}
]
```
**To see what data is available for individual Rules [consult the schema](#configuration) for each Rule.**
#### 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:
```
myVariable = 50;
myOtherVariable = "a text fragment"
template = "This is my template, the variable is {{myVariable}}, my other variable is {{myOtherVariable}}, and that's it!";
console.log(Mustache.render(template, {myVariable});
// will render...
"This is my template, the variable is 50, my other variable is a text fragment, and that's it!";
```
**Note: When accessing an object or its properties you must use dot notation**
```
const item = {
aProperty: 'something',
anotherObject: {
bProperty: 'something else'
}
}
const content = "My content will render the property {{item.aProperty}} like this, and another nested property {{item.anotherObject.bProperty}} like this."
```

94
docs/botAuthentication.md Normal file
View File

@@ -0,0 +1,94 @@
**Note:** This is for **bot operators.** If you are a subreddit moderator check out the **[Getting Started Guide](/docs/gettingStartedMod.md)**
Before you can start using your bot on reddit there are a few steps you must take:
* Create your bot account IE the reddit account that will be the "bot"
* Create a Reddit application
* Authenticate your bot account with the application
At the end of this process you will have this info:
* clientId
* clientSecret
* refreshToken
* accessToken
**Note:** If you already have this information you can skip this guide **but make sure your redirect uri is correct if you plan on using the web interface.**
# Table Of Contents
* [Creating an Application](#create-application)
* [Authenticate Your Bot](#authenticate-your-bot-account)
* [Using CM OAuth Helper](#cm-oauth-helper-recommended)
* [Using Aardvark OAuth Helper](#aardvark-oauth-helper)
* [Provide Credentials to CM](#provide-credentials-to-cm)
# Create Application
Visit [your reddit preferences](https://www.reddit.com/prefs/apps) and at the bottom of the page go through the **create an(other) app** process.
* Give it a **name**
* Choose **web app**
* If you know what you will use for **redirect uri** go ahead and use it, otherwise use **http://localhost:8085** for now
Click **create app**.
Then write down your **Client ID, Client Secret, and redirect uri** somewhere (or keep this webpage open)
# Authenticate Your Bot Account
There are **two ways** you can authenticate your bot account. It is recommended to use the CM oauth helper.
## CM OAuth Helper (Recommended)
This method will use CM's built in oauth flow. It is recommended because it will ensure your bot is authenticated with the correct oauth permissions.
### Start CM with Client ID/Secret
Start the application while providing the **Client ID** and **Client Secret** you received. Refer to the [operator config guide](/docs/operatorConfiguration.md) if you need help with this.
Examples:
* CLI - `node src/index.js --clientId=myId --clientSecret=mySecret`
* Docker - `docker run -e "CLIENT_ID=myId" -e "CLIENT_SECRET=mySecret" foxxmd/context-mod`
Then open the CM web interface (default is [http://localhost:8085](http://localhost:8085))
Follow the directions in the helper to finish authenticating your bot and get your credentials (Access Token and Refresh Token)
## Aardvark OAuth Helper
This method should only be used if you cannot use the [CM OAuth Helper method](#cm-oauth-helper-recommended) because you cannot access the CM web interface.
* Visit [https://not-an-aardvark.github.io/reddit-oauth-helper/](https://not-an-aardvark.github.io/reddit-oauth-helper/) and follow the instructions given.
* **Note:** You will need to update your **redirect uri.**
* Input your **Client ID** and **Client Secret** in the text boxes with those names.
* Choose scopes. **It is very important you check everything on this list or CM may not work correctly**
* edit
* flair
* history
* identity
* modcontributors
* modflair
* modposts
* modself
* mysubreddits
* read
* report
* submit
* wikiread
* wikiedit (if you are using Toolbox User Notes)
* Click **Generate tokens**, you will get a popup asking you to approve access (or login) -- **the account you approve access with is the account that Bot will control.**
* After approving an **Access Token** and **Refresh Token** will be shown at the bottom of the page. Save these to use with CM.
# Provide Credentials to CM
At the end of the last step you chose you should now have this information saved somewhere:
* clientId
* clientSecret
* refreshToken
* accessToken
This is all the information you need to run your bot with CM.
Using these credentials follow the [operator config guide](/docs/operatorConfiguration.md) to finish setting up your CM instance.

View File

@@ -1,6 +1,6 @@
# Examples
This directory contains example of valid, ready-to-go configurations for Context Bot for the purpose of:
This directory contains example of valid, ready-to-go configurations for Context Mod for the purpose of:
* showcasing what the bot can do
* providing best practices for writing your configuration
@@ -21,5 +21,6 @@ This directory contains example of valid, ready-to-go configurations for Context
* [Rule Sets](/docs/examples/advancedConcepts/ruleSets.json5)
* [Name Rules](/docs/examples/advancedConcepts/ruleNameReuse.json5)
* [Check Ordering](/docs/examples/advancedConcepts)
* Subreddit-ready examples
* Coming soon...
* [Subreddit-ready examples](/docs/examples/subredditReady)
PROTIP: You can edit/build on examples by using the [schema editor.](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json)

View File

@@ -23,7 +23,7 @@ The `rules` array on a `Checks` can contain both `Rule` objects and `RuleSet` ob
A **Rule Set** is a "nested" set of `Rule` objects with a passing condition specified. These allow you to create more complex trigger behavior by combining multiple rules.
See **[ruleSets.json5](/docs/examples/advancedConcepts/ruleSets.json5)** for a complete example as well as consulting the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRuleSetJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json).
See **[ruleSets.json5](/docs/examples/advancedConcepts/ruleSets.json5)** for a complete example as well as consulting the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRuleSetJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json).
### Rule Order
@@ -45,7 +45,7 @@ If the Check is using `AND` condition for its rules (default) then if either Rul
### API Caching
Context bot implements some basic caching functionality for **Author Activities** and wiki pages (on Comment/Report Actions).
Context Mod implements some basic caching functionality for **Author Activities** and wiki pages (on Comment/Report Actions).
**Author Activities** are cached for a subreddit-configurable amount of time (10 seconds by default). A cached activities set can be re-used if the **window on a Rule is identical to the window on another Rule**.

View File

@@ -6,7 +6,7 @@ The **Attribution** rule will aggregate an Author's content Attribution (youtube
* Look at all domains or only media (youtube, vimeo, etc.)
* Include self posts (by reddit domain) or not
Consult the [schema](https://json-schema.app/view/%23/%23%2Fdefinitions%2FCheckJson/%23%2Fdefinitions%2FAttributionJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
Consult the [schema](https://json-schema.app/view/%23/%23%2Fdefinitions%2FCheckJson/%23%2Fdefinitions%2FAttributionJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
### Examples

View File

@@ -2,7 +2,7 @@
## Rule
The **Author** rule triggers if any [AuthorCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) from a list are either **included** or **excluded**, depending on which property you put them in.
The **Author** rule triggers if any [AuthorCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) from a list are either **included** or **excluded**, depending on which property you put them in.
**AuthorCriteria** that can be checked:
* name (u/userName)
@@ -13,7 +13,7 @@ The **Author** rule triggers if any [AuthorCriteria](https://json-schema.app/vie
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).
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorRuleJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorRuleJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
### Examples
@@ -25,7 +25,7 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorRule
## Filter
All **Rules** and **Checks** have an optional `authorIs` property that takes an [AuthorOptions](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorOptions?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) object.
All **Rules** and **Checks** have an optional `authorIs` property that takes an [AuthorOptions](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorOptions?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) object.
**This property works the same as the Author Rule except that:**
* On **Rules** if all criteria fail the Rule is **skipped.**

View File

@@ -5,7 +5,7 @@ The **History** rule can check an Author's submission/comment statistics over a
* Comment total or percentage of all Activity
* Comments made as OP (commented in their own Submission) total or percentage of all Comments
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FHistoryJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
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.
### Examples

View File

@@ -2,7 +2,7 @@
The **Recent Activity** rule can check if an Author has made any Submissions/Comments in a list of defined Subreddits.
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRecentActivityRuleJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRecentActivityRuleJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
### Examples

View File

@@ -1,6 +1,6 @@
# Repeat Activity
The **Repeat Activity** rule will check for patterns of repetition in an Author's Submission/Comment history. Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRepeatActivityJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
The **Repeat Activity** rule will check for patterns of repetition in an Author's Submission/Comment history. Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRepeatActivityJSONConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
## Tuning

View File

@@ -0,0 +1,41 @@
Provided here are **complete, ready-to-go configuration** that can copy-pasted straight into your configuration wiki page to get going with ContextMod immediately.
These configurations attempt to provide sensible, non-destructive, default behavior for some common scenarios and subreddit types.
In most cases these will perform decently out-of-the-box but they are not perfect. You should still monitor bot behavior to see how it performs and will most likely still need to tweak these configurations to get your desired behavior.
All actions for these configurations are non-destructive in that:
* All instances where an activity would be modified (remove/ban/approve) will have `dryRun: true` set to prevent the action from actually being performed
* These instances will also have a `report` action detailing the action would have been performed
**You will have to remove the `report` action and `dryRun` settings yourself.** This is to ensure that you understand the behavior the bot will be performing. If you are unsure of this you should leave them in place until you are certain the behavior the bot is performing is acceptable.
## Submission-based Behavior
### [Remove submissions from users who have used 'freekarma' subs to bypass karma checks](/docs/examples/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](/docs/examples/subredditReady/crosspostSpam.json5)
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](/docs/examples/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](/docs/examples/subredditReady/selfPromo.json5)
If the link origin (youtube author, twitter author, etc. or regular domain for non-media links)
* comprises 10% or more of the users **entire** history in the past (100 activities or 6 months)
* or comprises 10% or more of the users **submission** history in the past (100 activities or 6 months) and the user has low engagement (<50% of history is comments or 40%> of comment are as OP)
then remove the submission
## Comment-based behavior
### [Remove comment if the user has posted the same comment 4 or more times in a row](/docs/examples/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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
# [Toolbox](https://www.reddit.com/r/toolbox/wiki/docs) [User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes)
Context Bot supports reading and writing [User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) for the [Toolbox](https://www.reddit.com/r/toolbox/wiki/docs) extension.
Context Mod supports reading and writing [User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) for the [Toolbox](https://www.reddit.com/r/toolbox/wiki/docs) extension.
**You must have Toolbox setup for your subreddit and at least one User Note created before you can use User Notes related features on Context Bot.**
@@ -8,9 +8,9 @@ Context Bot supports reading and writing [User Notes](https://www.reddit.com/r/t
## Filter
User Notes are an additional criteria on [AuthorCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) that can be used alongside other Author properties for both [filtering rules and in the AuthorRule.](/docs/examples/author/)
User Notes are an additional criteria on [AuthorCriteria](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) that can be used alongside other Author properties for both [filtering rules and in the AuthorRule.](/docs/examples/author/)
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the **UserNoteCriteria** object that can be used in AuthorCriteria.
Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteCriteria?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the **UserNoteCriteria** object that can be used in AuthorCriteria.
### Examples
@@ -18,7 +18,7 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteCr
## Action
A User Note can also be added to the Author of a Submission or Comment with the [UserNoteAction.](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
A User Note can also be added to the Author of a Submission or Comment with the [UserNoteAction.](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json)
### Examples

View File

@@ -1,19 +0,0 @@
### Creating Your Configuration
#### Get the raw contents of the configuration
* In a new tab open the github page for the configuration you want ([example](/docs/examples/repeatActivity/crosspostSpamming.json5))
* Click the **Raw** button...keep this tab open and move on to the next step
#### Edit your wiki configuration
* Visit the wiki page of the subreddit you want the bot to moderate
* Using default bot settings this will be `https://old.reddit.com/r/YOURSUBERDDIT/wiki/botconfig/contextbot`
* If the page does not exist create it, otherwise click **Edit**
* Copy-paste the configuration into the wiki text box
* In the previous tab you opened (for the configuration) **Select All** (Ctrl+A), then **Copy**
* On the wiki page **Paste** into the text box
* Save the edited wiki page
* Ensure the wiki page visibility is restricted
* On the wiki page click **settings** (**Page settings** in new reddit)
* Check the box for **Only mods may edit and view** and then **save**

97
docs/gettingStartedMod.md Normal file
View File

@@ -0,0 +1,97 @@
This getting started guide is for **reddit moderators** -- that is, someone who wants **an existing ContextMod bot to run on their subreddit.** If you are trying to run a ContextMod
instance (software) please refer to the [operator getting started](/docs/gettingStartedOperator.md) guide.
# Table of Contents
* [Prior Knowledge](#prior-knowledge)
* [Mod the Bot](#mod-the-bot)
* [Creating Configuration](#configuring-the-bot)
* [Monitor the Bot](#monitor-the-bot)
# Prior Knowledge
Before continuing with this guide you should first make sure you understand how a ContextMod works. Please review this documentation:
* [How It Works](/docs#how-it-works)
* [Core Concepts](/docs#concepts)
# Mod The Bot
First ensure that you are in communication with the **operator** for this bot. The bot **will not automatically accept a moderator invitation,** it must be manually done by the bot operator. This is an intentional barrier to ensure moderators and the operator are familiar with their respective needs and have some form of trust.
Now invite the bot to moderate your subreddit. The bot should have at least these permissions:
* Manage Users
* Manage Posts and Comments
* Manage Flair
Additionally, the bot must have the **Manage Wiki Pages** permission if you plan to use [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes). If you are not planning on using this feature and do not want the bot to have this permission then you **must** ensure the bot has visibility to the configuration wiki page (detailed below).
# Configuring the Bot
## Setup wiki page
* Visit the wiki page of the subreddit you want the bot to moderate
* The default location the bot checks for a configuration is at `https://old.reddit.com/r/YOURSUBERDDIT/wiki/botconfig/contextbot`
* If the page does not exist create it
* Ensure the wiki page visibility is restricted
* On the wiki page click **settings** (**Page settings** in new reddit)
* Check the box for **Only mods may edit and view** and then **save**
* Alternatively, if you did not give the bot the **Manage Wiki Pages** permission then add it to the **allow users to edit page** setting
## Procure a configuration
Now you need to make the actual configuration that will be used to configure the bot's behavior on your subreddit. This may have already been done for you by your operator or you may be copying a fellow moderator's configuration.
If you already have a configuration you may skip the below step and go directly to [saving your configuration](#saving-your-configuration)
### Using an Example Config
Visit the [Examples](https://github.com/FoxxMD/context-mod/tree/master/docs/examples) folder to find various examples of individual rules or see the [subreddit-ready examples.](/docs/examples/subredditReady)
After you have found a configuration to use as a starting point:
* In a new tab open the github page for the configuration you want ([example](/docs/examples/repeatActivity/crosspostSpamming.json5))
* Click the **Raw** button, then select all and copy all of the text to your clipboard.
### Build Your Own Config
Additionally, you can use [this schema editor](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) to build your configuration. The editor features a ton of handy features:
* fully annotated configuration data/structure
* generated examples in json/yaml
* built-in editor that automatically validates your config
PROTIP: Find an example config to use as a starting point and then build on it using the editor.
## Saving Your Configuration
* Open the wiki page you created in the [previous step](#setup-wiki-page) and click **edit**
* Copy-paste your configuration into the wiki text box
* Save the edited wiki page
___
The bot automatically checks for new configurations on your wiki page every 5 minutes. If your operator has the web interface accessible you may login there and force the config to update on your subreddit.
# Monitor the Bot
Monitoring the behavior of the bot is dependent on how your operator setup their instance. ContextMod comes with a built-in web interface that is secure and accessible only to moderates of subreddits it is running on. However there is some additional setup for the operator to perform in order to make this interface accessible publicly. If you do not have access to this interface please communicate with your operator.
After logging in to the interface you will find your subreddit in a tab at the top of the web page. Selecting your subreddit will give you access to:
* Current status of the bot
* Current status of your configuration
* Statistics pertaining to the number of checks/rules/actions run and cache usage
* **A real-time view for bot logs pertaining to your subreddit**
The logs are the meat and potatoes of the bot and the main source of feedback you have for fine-tuning the bot's behavior. The **verbose** log level will show you:
* The event being processed
* The individual results of triggered rules, per check
* The checks that were run and their rules
* The actions performed, with markdown content preview, of triggered checks
This information should enable you to tweak the criteria for your rules in order to get the required behavior from the bot.
Additionally, you can test your bot on any comment/submission by entering its permalink in the text bot at the top of the logs and selecting **Dry Run** -- this will run the bot on an Activity without actually performing any actions allowing you to preview the results of a run.

View File

@@ -0,0 +1,71 @@
This getting started guide is for **Operators** -- that is, someone who wants to run the actual software for a ContentMod bot. If you are a **Moderator** check out the [moderator getting started](/docs/gettingStartedMod.md) guide instead.
# Table of Contents
* [Installation](#installation)
* [Docker](#docker-recommended)
* [Locally](#locally)
* [Heroku](#heroku-quick-deployhttpsherokucomabout)
* [Bot Authentication](#bot-authentication)
* [Instance Configuration](#instance-configuration)
* [Run Your Bot and Start Moderating](#run-your-bot-and-start-moderating)
# Installation
In order to run a ContextMod instance you must first you must install it somewhere.
ContextMod can be run on almost any operating system but it is recommended to use Docker due to ease of deployment.
## Docker (Recommended)
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)
```
foxxmd/context-mod:latest
```
Adding **environmental variables** to your `docker run` command will pass them through to the app EX:
```
docker run -d -e "CLIENT_ID=myId" ... foxxmd/context-mod
```
### Locally
Requirements:
* Typescript >=4.3.5
* Node >=15
Clone this repository somewhere and then install from the working directory
```bash
git clone https://github.com/FoxxMD/context-mod.git .
cd context-mod
npm install
tsc -p .
```
### [Heroku Quick Deploy](https://heroku.com/about)
[![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://dashboard.heroku.com/new?template=https://github.com/FoxxMD/context-mod)
# Bot Authentication
Next you need to create your bot and authenticate it with Reddit. Follow the [bot authentication guide](/docs/botAuthentication.md) to complete this step.
# Instance Configuration
Finally, you must provide the credentials you received from the **Bot Authentication** step to the ContextMod instance you installed earlier. Refer to the [Operator Configuration](/docs/operatorConfiguration.md) guide to learn how this can be done as there are multiple approaches depending on how you installed the software.
Additionally, at this step you can also tweak many more settings and behavior concerning how your CM bot will operate.
# Run Your Bot and Start Moderating
Congratulations! You should now have a fully authenticated bot running on ContextMod software.
In order for your Bot to operate on reddit though it **must be a moderator in the subreddit you want it to run in.** This may be your own subreddit or someone else's.
**Note: ContextMod does not currently handle moderation invites automatically** and may never have this functionality. Due to the fact that many of its behaviors are api-heavy and that subreddits can control their own configuration the api and resource (cpu/memory) usage of a ContextMod instance can be highly variable. It therefore does not make sense to allow any/all subreddits to automatically have access to an instance through automatically accepting moderator invites. So...if you are planning to run a ContextMod instance for subreddits other than those you moderate you should establish solid trust with moderators of that subreddit as well as a solid line of communication in order to ensure their configurations can be tailored to best fit their needs and your resources.
Once you have logged in as your bot and manually accepted the moderator invite you will need to restart your ContextMod instance in order for these changes to take effect.

View File

@@ -6,6 +6,7 @@ activities the Bot runs on.
* [Minimum Required Configuration](#minimum-required-configuration)
* [Defining Configuration](#defining-configuration)
* [CLI Usage](#cli-usage)
* [Examples](#example-configurations)
* [Minimum Config](#minimum-config)
* [Using Config Overrides](#using-config-overrides)
@@ -20,13 +21,15 @@ The minimum required configuration variables to run the bot on subreddits are:
* refreshToken
* accessToken
However, only **clientId** and **clientSecret** are required to run the **oauth helper** mode for generate the last two
However, only **clientId** and **clientSecret** are required to run the **oauth helper** mode in order to generate the last two
configuration variables.
Refer to the **[Bot Authentication guide](/docs/botAuthentication.md)** to retrieve the above credentials.
# Defining Configuration
RCB can be configured using **any or all** of the approaches below. **At each level ALL configuration values are
optional** but some are required depending on the mode of operation for the application.
CM can be configured using **any or all** of the approaches below. Note that **at each level ALL configuration values are
optional** but the "required configuration" mentioned above must be available when all levels are combined.
Any values defined at a **lower-listed** level of configuration will override any values from a higher-listed
configuration.
@@ -34,177 +37,72 @@ configuration.
* **ENV** -- Environment variables loaded from an [`.env`](https://github.com/toddbluhm/env-cmd) file (path may be
specified with `--file` cli argument)
* **ENV** -- Any already existing environment variables (exported on command line/terminal profile/etc.)
* **FILE** -- Values specified in a JSON configuration file using the structure shown below (TODO example json file)
* **ARG** -- Values specified as CLI arguments to the program (see [Usage](/README.md#usage)
or `node src/index.js run help` for details)
* **FILE** -- Values specified in a JSON configuration file using the structure [in the schema](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FOperatorConfig.json)
* **ARG** -- Values specified as CLI arguments to the program (see [ClI Usage](#cli-usage) below)
In the below configuration, if the variable is available at a level of configuration other than **FILE** it will be
**Note:** When reading the **schema** if the variable is available at a level of configuration other than **FILE** it will be
noted with the same symbol as above. The value shown is the default.
**NOTE:** To load a JSON configuration (for **FILE**) use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`
* To load a JSON configuration (for **FILE**) **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`
* To load a JSON configuration (for **FILE**) **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`
[**See the Operator Config Schema here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FOperatorConfig.json)
## CLI Usage
Running CM from the command line is accomplished with the following command:
```bash
node src/index.js run
```js
const config = {
operator: {
// Username of the reddit user operating this application, used for displaying OP level info/actions in UI
//
// ENV => OPERATOR
// ARG => --operator <name>
name: undefined,
// An optional name to display who is operating this application in the UI
//
// ENV => OPERATOR_DISPLAY
// ARG => --operator <name>
display: undefined,
},
// Values required to interact with Reddit's API
credentials: {
// Client ID for your Reddit application
//
// ENV => CLIENT_ID
// ARG => --clientId <id>
clientId: undefined,
// Client Secret for your Reddit application
//
// ENV => CLIENT_SECRET
// ARG => --clientSecret <secret>
clientSecret: undefined,
// Redirect URI for your Reddit application
//
// ENV => REDIRECT_URI
// ARG => --redirectUri <uri>
redirectUri: undefined,
// Access token retrieved from authenticating an account with your Reddit Application
//
// ENV => ACCESS_TOKEN
// ARG => --accessToken <token>
accessToken: undefined,
// Refresh token retrieved from authenticating an account with your Reddit Application
//
// ENV => REFRESH_TOKEN
// ARG => --refreshToken <token>
refreshToken: undefined
},
logging: {
// Minimum level to log at.
// Must be one of: error, warn, info, verbose, debug
//
// ENV => LOG_LEVEL
// ARG => --logLevel <level>
level: 'verbose',
// Absolute path to directory to store rotated logs in.
//
// Leaving undefined disables rotating logs
// Use ENV => true or ARG => --logDir to log to the current directory under /logs folder
//
// ENV => LOG_DIR
// ARG => --logDir [dir]
path: undefined,
},
snoowrap: {
// Proxy endpoint to make Snoowrap requests to
//
// ENV => PROXY
// ARG => --proxy <proxyEndpoint>
proxy: undefined,
// Set Snoowrap to log debug statements. If undefined will debug based on current log level
//
// ENV => SNOO_DEBUG
// ARG => --snooDebug
debug: false,
},
subreddits: {
// Names of subreddits for bot to run on
//
// If undefined bot will run on all subreddits it is a moderated of
//
// ENV => SUBREDDITS (comma-separated)
// ARG => --subreddits <list...>
names: undefined,
// If true set all subreddits in dry run mode, overriding configurations
//
// ENV => DRYRUN
// ARG => --dryRun
dryRun: false,
// The default relative url to contextbot wiki page EX https://reddit.com/r/subreddit/wiki/<path>
//
// ENV => WIKI_CONFIG
// ARG => --wikiConfig <path>
wikiConfig: 'botconfig/contextbot',
// Interval, in seconds, to perform application heartbeat
//
// ENV => HEARTBEAT
// ARG => --heartbeat <sec>
heartbeatInterval: 300,
},
polling: {
// If set to true all subreddits polling unmoderated/modqueue with default polling settings will share a request to "r/mod"
// otherwise each subreddit will poll its own mod view
//
// ENV => SHARE_MOD
// ARG => --shareMod
sharedMod: false,
// Default interval, in seconds, to poll activity sources at
interval: 30,
},
web: {
// Whether the web server interface should be started
// In most cases this does not need to be specified as the application will automatically detect if it is possible to start it --
// use this to specify 'cli' if you encounter errors with port/address or are paranoid
//
// ENV => WEB
// ARG => 'node src/index.js run [interface]' -- interface can be 'web' or 'cli'
enabled: true,
// Set the port for the web interface
//
// ENV => PORT
// ARG => --port <number>
port: 8085,
session: {
// The cache provider for sessions
// can be 'memory', 'redis', or a custom config
provider: 'memory',
// The secret value used to encrypt session data
// If provider is persistent (redis) specifying a value here will ensure sessions are valid between application restarts
//
// If undefined a random string is generated
secret: undefined,
},
// The default log level to filter to in the web interface
// If not specified will be same as application log level
logLevel: undefined,
// Maximum number of log statements to keep in memory for each subreddit
maxLogs: 200,
},
caching: {
// The default maximum age of cached data for an Author's history
//
// ENV => AUTHOR_TTL
// ARG => --authorTTL <sec>
authorTTL: 60,
// The default maximum age of cached usernotes for a subreddit
userNotesTTL: 300,
// The default maximum age of cached content, retrieved from an external URL or subreddit wiki, used for comments/ban/footer
wikiTTL: 300,
// The cache provider used for caching reddit API responses and some internal results
// can be 'memory', 'redis', or a custom config
provider: 'memory'
},
api: {
// The number of API requests remaining at which "slow mode" should be enabled
//
// ENV => SOFT_LIMT
// ARG => --softLimit <limit>
softLimit: 250,
// The number of API requests remaining at at which all subreddit event polling should be paused
//
// ENV => HARD_LIMIT
// ARG => --hardLimit <limit>
hardLimit: 50,
}
}
```
Run `node src/index.js run help` to get a list of available command line options (denoted by **ARG** above):
<details>
```
Usage: index [options] [command]
Options:
-h, --help display help for command
Commands:
run [options] [interface] Monitor new activities from configured subreddits.
check [options] <activityIdentifier> [type] Run check(s) on a specific activity
unmoderated [options] <subreddits...> Run checks on all unmoderated activity in the modqueue
help [command] display help for command
Options:
-c, --operatorConfig <path> An absolute path to a JSON file to load all parameters from (default: process.env.OPERATOR_CONFIG)
-i, --clientId <id> Client ID for your Reddit application (default: process.env.CLIENT_ID)
-e, --clientSecret <secret> Client Secret for your Reddit application (default: process.env.CLIENT_SECRET)
-a, --accessToken <token> Access token retrieved from authenticating an account with your Reddit Application (default: process.env.ACCESS_TOKEN)
-r, --refreshToken <token> Refresh token retrieved from authenticating an account with your Reddit Application (default: process.env.REFRESH_TOKEN)
-u, --redirectUri <uri> Redirect URI for your Reddit application (default: process.env.REDIRECT_URI)
-t, --sessionSecret <secret> Secret use to encrypt session id/data (default: process.env.SESSION_SECRET || a random string)
-s, --subreddits <list...> List of subreddits to run on. Bot will run on all subs it has access to if not defined (default: process.env.SUBREDDITS)
-d, --logDir [dir] Absolute path to directory to store rotated logs in. Leaving undefined disables rotating logs (default: process.env.LOG_DIR)
-l, --logLevel <level> Minimum level to log at (default: process.env.LOG_LEVEL || verbose)
-w, --wikiConfig <path> Relative url to contextbot wiki page EX https://reddit.com/r/subreddit/wiki/<path> (default: process.env.WIKI_CONFIG || 'botconfig/contextbot')
--snooDebug Set Snoowrap to debug. If undefined will be on if logLevel='debug' (default: process.env.SNOO_DEBUG)
--authorTTL <s> Set the TTL (seconds) for the Author Activities shared cache (default: process.env.AUTHOR_TTL || 60)
--heartbeat <s> Interval, in seconds, between heartbeat checks. (default: process.env.HEARTBEAT || 300)
--softLimit <limit> When API limit remaining (600/10min) is lower than this subreddits will have SLOW MODE enabled (default: process.env.SOFT_LIMIT || 250)
--hardLimit <limit> When API limit remaining (600/10min) is lower than this all subreddit polling will be paused until api limit reset (default: process.env.SOFT_LIMIT || 250)
--dryRun Set all subreddits in dry run mode, overriding configurations (default: process.env.DRYRUN || false)
--proxy <proxyEndpoint> Proxy Snoowrap requests through this endpoint (default: process.env.PROXY)
--operator <name...> Username(s) of the reddit user(s) operating this application, used for displaying OP level info/actions in UI (default: process.env.OPERATOR)
--operatorDisplay <name> An optional name to display who is operating this application in the UI (default: process.env.OPERATOR_DISPLAY || Anonymous)
-p, --port <port> Port for web server to listen on (default: process.env.PORT || 8085)
-q, --shareMod If enabled then all subreddits using the default settings to poll "unmoderated" or "modqueue" will retrieve results from a shared request to /r/mod (default: process.env.SHARE_MOD || false)
-h, --help display help for command
```
</details>
# Example Configurations
## Minimum Config
@@ -252,7 +150,7 @@ node src/index.js run --clientId=f4b4df1c7b2 --clientSecret=34v5q1c56ub --refres
## Using Config Overrides
Using all three configs together:
An example of using multiple configuration levels together IE all are provided to the application:
**FILE**
<details>
@@ -292,7 +190,7 @@ node src/index.js run --subreddits=sub1
</details>
Produces these variables at runtime for the application:
When all three are used together they produce these variables at runtime for the application:
```
clientId: f4b4df1c7b2
@@ -306,7 +204,7 @@ log level: debug
# Cache Configuration
RCB implements two caching backend **providers**. By default all providers use `memory`:
CM implements two caching backend **providers**. By default all providers use `memory`:
* `memory` -- in-memory (non-persistent) backend
* `redis` -- [Redis](https://redis.io/) backend

BIN
docs/screenshots/oauth.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

762
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,12 @@
{
"name": "redditcontextbot",
"version": "1.0.0",
"version": "0.5.1",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no tests installed\" && exit 1",
"build": "tsc",
"start": "node server.js",
"start": "node src/index.js run",
"guard": "ts-auto-guard src/JsonConfig.ts",
"schema": "npm run -s schema-app & npm run -s schema-ruleset & npm run -s schema-rule & npm run -s schema-action & npm run -s schema-config",
"schema-app": "typescript-json-schema tsconfig.json JSONConfig --out src/Schema/App.json --required --tsNodeRegister --refs",
@@ -46,10 +46,13 @@
"he": "^1.2.0",
"js-yaml": "^4.1.0",
"json5": "^2.2.0",
"lodash": "^4.17.21",
"lru-cache": "^6.0.0",
"mustache": "^4.2.0",
"node-fetch": "^2.6.1",
"object-hash": "^2.2.0",
"p-event": "^4.2.0",
"pretty-print-json": "^1.0.3",
"safe-stable-stringify": "^1.1.1",
"snoostorm": "^1.5.2",
"snoowrap": "^1.23.0",
@@ -71,14 +74,13 @@
"@types/express-socket.io-session": "^1.3.6",
"@types/he": "^1.1.1",
"@types/js-yaml": "^4.0.1",
"@types/lodash": "^4.14.171",
"@types/lru-cache": "^5.1.1",
"@types/memory-cache": "^0.2.1",
"@types/minimist": "^1.2.1",
"@types/mustache": "^4.1.1",
"@types/node": "^15.6.1",
"@types/node-fetch": "^2.5.10",
"@types/object-hash": "^2.1.0",
"@types/pako": "^1.0.1",
"@types/tcp-port-used": "^1.0.0",
"ts-auto-guard": "*",
"ts-json-schema-generator": "^0.93.0",

View File

@@ -8,6 +8,7 @@ import {Logger} from "winston";
import {UserNoteAction, UserNoteActionJson} from "./UserNoteAction";
import ApproveAction, {ApproveActionConfig} from "./ApproveAction";
import BanAction, {BanActionJson} from "./BanAction";
import {MessageAction, MessageActionJson} from "./MessageAction";
export function actionFactory
(config: ActionJson, logger: Logger, subredditName: string): Action {
@@ -28,6 +29,8 @@ export function actionFactory
return new UserNoteAction({...config as UserNoteActionJson, logger, subredditName});
case 'ban':
return new BanAction({...config as BanActionJson, logger, subredditName});
case 'message':
return new MessageAction({...config as MessageActionJson, logger, subredditName});
default:
throw new Error('rule "kind" was not recognized.');
}

View File

@@ -0,0 +1,94 @@
import Action, {ActionJson, ActionOptions} from "./index";
import {Comment, ComposeMessageParams} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {renderContent, singleton} from "../Utils/SnoowrapUtils";
import {Footer, RequiredRichContent, RichContent} from "../Common/interfaces";
import {RuleResult} from "../Rule";
import {boolToString} from "../util";
export class MessageAction extends Action {
content: string;
lock: boolean = false;
sticky: boolean = false;
distinguish: boolean = false;
footer?: false | string;
title?: string;
asSubreddit: boolean;
constructor(options: MessageActionOptions) {
super(options);
const {
content,
asSubreddit,
title,
footer,
} = options;
this.footer = footer;
this.content = content;
this.asSubreddit = asSubreddit;
this.title = title;
}
getKind() {
return 'Message';
}
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<void> {
const dryRun = runtimeDryrun || this.dryRun;
const content = await this.resources.getContent(this.content);
const body = await renderContent(content, item, ruleResults, this.resources.userNotes);
const footer = await this.resources.generateFooter(item, this.footer);
const renderedContent = `${body}${footer}`;
// @ts-ignore
const author = await item.author.fetch() as RedditUser;
const client = singleton.getClient();
const msgOpts: ComposeMessageParams = {
to: author,
text: renderedContent,
// @ts-ignore
fromSubreddit: this.asSubreddit ? await item.subreddit.fetch() : undefined,
subject: this.title || `Concerning your ${item instanceof Submission ? 'Submission' : 'Comment'}`,
};
const msgPreview = `\r\n
TO: ${author.name}\r\n
Subject: ${msgOpts.subject}\r\n
Sent As Modmail: ${boolToString(this.asSubreddit)}\r\n\r\n
${renderedContent}`;
this.logger.verbose(`Message Preview => \r\n ${msgPreview}`);
if (!dryRun) {
await client.composeMessage(msgOpts);
}
}
}
export interface MessageActionConfig extends RequiredRichContent, Footer {
/**
* Should this message be sent from modmail (as the subreddit) or as the bot user?
* */
asSubreddit: boolean
/**
* The title of the message
*
* If not specified will be defaulted to `Concerning your [Submission/Comment]`
* */
title?: string
}
export interface MessageActionOptions extends MessageActionConfig, ActionOptions {
}
/**
* Send a private message to the Author of the Activity.
* */
export interface MessageActionJson extends MessageActionConfig, ActionJson {
kind: 'message'
}

View File

@@ -1,11 +0,0 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SubmissionAction = void 0;
const index_1 = __importDefault(require("../index"));
class SubmissionAction extends index_1.default {
}
exports.SubmissionAction = SubmissionAction;
//# sourceMappingURL=index.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;;;;AAAA,qDAA8C;AAE9C,MAAsB,gBAAiB,SAAQ,eAAM;CAEpD;AAFD,4CAEC"}

View File

@@ -4,7 +4,6 @@ import {RuleResult} from "../Rule";
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
import {ChecksActivityState, TypedActivityStates} from "../Common/interfaces";
import Author, {AuthorOptions} from "../Author/Author";
import {isItem} from "../Utils/SnoowrapUtils";
export abstract class Action {
name?: string;
@@ -13,9 +12,11 @@ export abstract class Action {
authorIs: AuthorOptions;
itemIs: TypedActivityStates;
dryRun: boolean;
enabled: boolean;
constructor(options: ActionOptions) {
const {
enable = true,
name = this.getKind(),
logger,
subredditName,
@@ -29,6 +30,7 @@ export abstract class Action {
this.name = name;
this.dryRun = dryRun;
this.enabled = enable;
this.resources = ResourceManager.get(subredditName) as SubredditResources;
this.logger = logger.child({labels: [`Action ${this.getActionUniqueName()}`]});
@@ -49,7 +51,7 @@ export abstract class Action {
async handle(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<void> {
const dryRun = runtimeDryrun || this.dryRun;
let actionRun = false;
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
const itemPass = await this.resources.testItemCriteria(item, this.itemIs);
if (!itemPass) {
this.logger.verbose(`Activity did not pass 'itemIs' test, Action not run`);
return;
@@ -124,13 +126,21 @@ export interface ActionConfig extends ChecksActivityState {
*
* */
itemIs?: TypedActivityStates
/**
* If set to `false` the Action will not be run
*
* @default true
* @examples [true]
* */
enable?: boolean
}
export interface ActionJson extends ActionConfig {
/**
* The type of action that will be performed
*/
kind: 'comment' | 'lock' | 'remove' | 'report' | 'approve' | 'ban' | 'flair' | 'usernote'
kind: 'comment' | 'lock' | 'remove' | 'report' | 'approve' | 'ban' | 'flair' | 'usernote' | 'message'
}
export const isActionJson = (obj: object): obj is ActionJson => {

View File

@@ -20,6 +20,7 @@ import {ModQueueStream, UnmoderatedStream} from "./Subreddit/Streams";
import {getLogger} from "./Utils/loggerFactory";
import {DurationString, OperatorConfig, PAUSED, RUNNING, STOPPED, SYSTEM, USER} from "./Common/interfaces";
import { Duration } from "dayjs/plugin/duration";
import {singleton} from "./Utils/SnoowrapUtils";
const {transports} = winston;
@@ -48,7 +49,9 @@ export class App {
hardLimit: number | string = 50;
nannyMode?: 'soft' | 'hard';
nextExpiration!: Dayjs;
botName?: string;
botName!: string;
botLink!: string;
maxWorkers: number;
startedAt: Dayjs = dayjs();
sharedModqueue: boolean = false;
@@ -60,6 +63,10 @@ export class App {
constructor(config: OperatorConfig) {
const {
operator: {
botName,
name,
},
subreddits: {
names = [],
wikiConfig,
@@ -79,6 +86,9 @@ export class App {
polling: {
sharedMod,
},
queue: {
maxWorkers,
},
caching: {
authorTTL,
provider: {
@@ -100,9 +110,21 @@ export class App {
this.hardLimit = hardLimit;
this.wikiLocation = wikiConfig;
this.sharedModqueue = sharedMod;
if(botName !== undefined) {
this.botName = botName;
}
this.logger = getLogger(config.logging);
this.logger.info(`Operators: ${name.length === 0 ? 'None Specified' : name.join(', ')}`)
let mw = maxWorkers;
if(maxWorkers < 1) {
this.logger.warn(`Max queue workers must be greater than or equal to 1 (Specified: ${maxWorkers})`);
mw = 1;
}
this.maxWorkers = mw;
if (this.dryRun) {
this.logger.info('Running in DRYRUN mode');
}
@@ -140,6 +162,8 @@ export class App {
continueAfterRatelimitError: true,
});
singleton.setClient(this.client);
const retryHandler = createRetryHandler({maxRequestRetry: 8, maxOtherRetry: 1}, this.logger);
const modStreamErrorListener = (name: string) => async (err: any) => {
@@ -165,16 +189,12 @@ export class App {
defaultModqueueStream.on('error', modStreamErrorListener('modqueue'));
CacheManager.modStreams.set('unmoderated', defaultUnmoderatedStream);
CacheManager.modStreams.set('modqueue', defaultModqueueStream);
}
const onTerm = () => {
for(const m of this.subManagers) {
m.notificationManager.handle('runStateChanged', 'Application Shutdown', 'The application was shutdown');
}
async onTerminate(reason = 'The application was shutdown') {
for(const m of this.subManagers) {
await m.notificationManager.handle('runStateChanged', 'Application Shutdown', reason);
}
process.on('SIGTERM', () => {
onTerm();
});
}
async testClient() {
@@ -202,15 +222,23 @@ export class App {
async buildManagers(subreddits: string[] = []) {
let availSubs = [];
const name = await this.client.getMe().name;
// @ts-ignore
const user = await this.client.getMe().fetch();
this.botLink = `https://reddit.com/user/${user.name}`;
this.logger.info(`Reddit API Limit Remaining: ${this.client.ratelimitRemaining}`);
this.logger.info(`Authenticated Account: /u/${name}`);
this.botName = name;
this.logger.info(`Authenticated Account: u/${user.name}`);
const botNameFromConfig = this.botName !== undefined;
if(this.botName === undefined) {
this.botName = `u/${user.name}`;
}
this.logger.info(`Bot Name${botNameFromConfig ? ' (from config)' : ''}: ${this.botName}`);
for (const sub of await this.client.getModeratedSubreddits()) {
// TODO don't know a way to check permissions yet
availSubs.push(sub);
}
this.logger.info(`/u/${name} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
this.logger.info(`${this.botName} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
let subsToRun: Subreddit[] = [];
const subsToUse = subreddits.length > 0 ? subreddits.map(parseSubredditName) : this.subreddits;
@@ -235,7 +263,7 @@ export class App {
let subSchedule: Manager[] = [];
// get configs for subs we want to run on and build/validate them
for (const sub of subsToRun) {
const manager = new Manager(sub, this.client, this.logger, {dryRun: this.dryRun, sharedModqueue: this.sharedModqueue});
const manager = new Manager(sub, this.client, this.logger, {dryRun: this.dryRun, sharedModqueue: this.sharedModqueue, wikiLocation: this.wikiLocation, botName: this.botName, maxWorkers: this.maxWorkers});
try {
await manager.parseConfiguration('system', true, {suppressNotification: true});
} catch (err) {

View File

@@ -1,12 +1,28 @@
import {Check, CheckOptions} from "./index";
import {Check, CheckOptions, userResultCacheDefault, UserResultCacheOptions} from "./index";
import {CommentState} from "../Common/interfaces";
import {Submission, Comment} from "snoowrap/dist/objects";
export interface CommentCheckOptions extends CheckOptions {
cacheUserResult?: UserResultCacheOptions;
}
export class CommentCheck extends Check {
itemIs: CommentState[];
constructor(options: CheckOptions) {
cacheUserResult: Required<UserResultCacheOptions>;
constructor(options: CommentCheckOptions) {
super(options);
const {itemIs = []} = options;
const {
itemIs = [],
cacheUserResult = {},
} = options;
this.cacheUserResult = {
...userResultCacheDefault,
...cacheUserResult
}
this.itemIs = itemIs;
this.logSummary();
}
@@ -14,4 +30,25 @@ export class CommentCheck extends Check {
logSummary() {
super.logSummary('comment');
}
async getCacheResult(item: Submission | Comment): Promise<boolean | undefined> {
if (this.cacheUserResult.enable) {
return await this.resources.getCommentCheckCacheResult(item as Comment, {
name: this.name,
authorIs: this.authorIs,
itemIs: this.itemIs
})
}
return undefined;
}
async setCacheResult(item: Submission | Comment, result: boolean): Promise<void> {
if (this.cacheUserResult.enable) {
await this.resources.setCommentCheckCacheResult(item as Comment, {
name: this.name,
authorIs: this.authorIs,
itemIs: this.itemIs
}, result, this.cacheUserResult.ttl)
}
}
}

View File

@@ -1,6 +1,6 @@
import {Check, CheckOptions} from "./index";
import {SubmissionState} from "../Common/interfaces";
import {Submission, Comment} from "snoowrap/dist/objects";
export class SubmissionCheck extends Check {
itemIs: SubmissionState[];
@@ -15,4 +15,11 @@ export class SubmissionCheck extends Check {
logSummary() {
super.logSummary('submission');
}
async getCacheResult(item: Submission | Comment) {
return undefined;
}
async setCacheResult(item: Submission | Comment, result: boolean) {
}
}

View File

@@ -6,6 +6,7 @@ import {Comment, Submission} from "snoowrap";
import {actionFactory} from "../Action/ActionFactory";
import {ruleFactory} from "../Rule/RuleFactory";
import {
boolToString,
createAjvFactory,
FAIL,
mergeArr,
@@ -26,16 +27,16 @@ import * as RuleSchema from '../Schema/Rule.json';
import * as RuleSetSchema from '../Schema/RuleSet.json';
import * as ActionSchema from '../Schema/Action.json';
import {ActionObjectJson, RuleJson, RuleObjectJson, ActionJson as ActionTypeJson} from "../Common/types";
import {isItem} from "../Utils/SnoowrapUtils";
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
import {Author, AuthorCriteria, AuthorOptions} from "../Author/Author";
const checkLogName = truncateStringToLength(25);
export class Check implements ICheck {
export abstract class Check implements ICheck {
actions: Action[] = [];
description?: string;
name: string;
enabled: boolean;
condition: JoinOperands;
rules: Array<RuleSet | Rule> = [];
logger: Logger;
@@ -50,6 +51,7 @@ export class Check implements ICheck {
constructor(options: CheckOptions) {
const {
enable = true,
name,
description,
condition = 'AND',
@@ -65,6 +67,8 @@ export class Check implements ICheck {
dryRun,
} = options;
this.enabled = enable;
this.logger = options.logger.child({labels: [`CHK ${checkLogName(name)}`]}, mergeArr);
const ajv = createAjvFactory(this.logger);
@@ -142,7 +146,7 @@ export class Check implements ICheck {
}
runStats.push(`${this.actions.length} Actions`);
// not sure if this should be info or verbose
this.logger.info(`${type.toUpperCase()} (${this.condition})${this.notifyOnTrigger ? ' ||Notify on Trigger|| ' : ''} => ${runStats.join(' | ')}${this.description !== undefined ? ` => ${this.description}` : ''}`);
this.logger.info(`=${this.enabled ? 'Enabled' : 'Disabled'}= ${type.toUpperCase()} (${this.condition})${this.notifyOnTrigger ? ' ||Notify on Trigger|| ' : ''} => ${runStats.join(' | ')}${this.description !== undefined ? ` => ${this.description}` : ''}`);
if (this.rules.length === 0 && this.itemIs.length === 0 && this.authorIs.exclude.length === 0 && this.authorIs.include.length === 0) {
this.logger.warn('No rules, item tests, or author test found -- this check will ALWAYS PASS!');
}
@@ -162,11 +166,22 @@ export class Check implements ICheck {
}
}
abstract getCacheResult(item: Submission | Comment) : Promise<boolean | undefined>;
abstract setCacheResult(item: Submission | Comment, result: boolean): void;
async runRules(item: Submission | Comment, existingResults: RuleResult[] = []): Promise<[boolean, RuleResult[]]> {
try {
let allRuleResults: RuleResult[] = [];
let allResults: (RuleResult | RuleSetResult)[] = [];
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
// check cache results
const cacheResult = await this.getCacheResult(item);
if(cacheResult !== undefined) {
this.logger.verbose(`Skipping rules run because result was found in cache, Check Triggered Result: ${cacheResult}`);
return [cacheResult, allRuleResults];
}
const itemPass = await this.resources.testItemCriteria(item, this.itemIs);
if (!itemPass) {
this.logger.verbose(`${FAIL} => Item did not pass 'itemIs' test`);
return [false, allRuleResults];
@@ -250,6 +265,10 @@ export class Check implements ICheck {
this.logger.debug(`${dr ? 'DRYRUN - ' : ''}Running Actions`);
const runActions: Action[] = [];
for (const a of this.actions) {
if(!a.enabled) {
this.logger.info(`Action ${a.getActionUniqueName()} not run because it is not enabled.`);
continue;
}
try {
await a.handle(item, ruleResults, runtimeDryrun);
runActions.push(a);
@@ -296,6 +315,14 @@ export interface ICheck extends JoinCondition, ChecksActivityState {
* If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail.
* */
authorIs?: AuthorOptions
/**
* Should this check be run by the bot?
*
* @default true
* @examples [true]
* */
enable?: boolean,
}
export interface CheckOptions extends ICheck {
@@ -345,9 +372,35 @@ export interface SubmissionCheckJson extends CheckJson {
itemIs?: SubmissionState[]
}
/**
* Cache the result of this check based on the comment author and the submission id
*
* This is useful in this type of scenario:
*
* 1. This check is configured to run on comments for specific submissions with high volume activity
* 2. The rules being run are not dependent on the content of the comment
* 3. The rule results are not likely to change while cache is valid
* */
export interface UserResultCacheOptions {
enable?: boolean,
/**
* The amount of time, in seconds, to cache this result
*
* @default 60
* @examples [60]
* */
ttl?: number,
}
export const userResultCacheDefault: Required<UserResultCacheOptions> = {
enable: false,
ttl: 60,
}
export interface CommentCheckJson extends CheckJson {
kind: 'comment'
itemIs?: CommentState[]
cacheUserResult?: UserResultCacheOptions
}
export type CheckStructuredJson = SubmissionCheckStructuredJson | CommentCheckStructuredJson;

View File

@@ -1,2 +1,2 @@
export const cacheOptDefaults = {ttl: 60, max: 500, checkPeriod: 600};
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300};
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300, submissionTTL: 60, commentTTL: 60, filterCriteriaTTL: 60};

View File

@@ -240,7 +240,7 @@ export interface RichContent {
*
* * EX `this is **bold** markdown text` => "this is **bold** markdown text"
*
* All Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).
* All Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).
*
* The following properties are always available in the template (view individual Rules to see rule-specific template data):
* ```
@@ -397,6 +397,27 @@ export interface TTLConfig {
* @default 300
* */
userNotesTTL?: number;
/**
* Amount of time, in seconds, a submission should be cached
* @examples [60]
* @default 60
* */
submissionTTL?: number;
/**
* Amount of time, in seconds, a comment should be cached
* @examples [60]
* @default 60
* */
commentTTL?: number;
/**
* Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)
*
* This is especially useful if when polling high-volume comments and your checks rely on author/item filters
*
* @examples [60]
* @default 60
* */
filterCriteriaTTL?: number;
}
export interface SubredditCacheConfig extends TTLConfig {
@@ -409,7 +430,7 @@ export interface Footer {
*
* If `false` no footer is appended
*
* If `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).
* If `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).
*
* If footer is `undefined` (not set) the default footer will be used:
*
@@ -418,7 +439,7 @@ export interface Footer {
*
* *****
*
* The following properties are available for [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* The following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):
* ```
* subName => name of subreddit Action was performed in (EX 'mealtimevideos')
* permaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x
@@ -460,6 +481,23 @@ export interface ManagerOptions {
* */
polling?: (string | PollingOptions)[]
queue?: {
/**
* The maximum number of events that can be processed simultaneously.
*
* **Do not modify this setting unless you know what you are doing.** The default of `1` is suitable for the majority of use-cases.
*
* Raising the max above `1` could be useful if you require very fast response time to short bursts of high-volume events. However logs may become unreadable as many events are processed at the same time. Additionally, any events that depend on past actions from your bot may not be processed correctly given the concurrent nature of this use case.
*
* **Note:** Max workers are also enforced at the operator level so a subreddit cannot raise their max above what is specified by the operator.
*
* @default 1
* @minimum 1
* @examples [1]
* */
maxWorkers?: number
}
/**
* Per-subreddit config for caching TTL values. If set to `false` caching is disabled.
* */
@@ -478,7 +516,7 @@ export interface ManagerOptions {
*
* If `false` no footer is appended
*
* If `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).
* If `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).
*
* If footer is `undefined` (not set) the default footer will be used:
*
@@ -487,7 +525,7 @@ export interface ManagerOptions {
*
* *****
*
* The following properties are available for [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* The following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):
* ```
* subName => name of subreddit Action was performed in (EX 'mealtimevideos')
* permaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x
@@ -581,6 +619,13 @@ export interface SubmissionState extends ActivityState {
* */
over_18?: boolean
is_self?: boolean
/**
* A valid regular expression to match against the title of the submission
* */
title?: string
link_flair_text?: string
link_flair_css_class?: string
}
/**
@@ -588,10 +633,14 @@ export interface SubmissionState extends ActivityState {
* @examples [{"op": true, "removed": false}]
* */
export interface CommentState extends ActivityState {
/*
/**
* Is this Comment Author also the Author of the Submission this comment is in?
* */
op?: boolean
/**
* A list of SubmissionState attributes to test the Submission this comment is in
* */
submissionState?: SubmissionState[]
}
export type TypedActivityStates = SubmissionState[] | CommentState[];
@@ -632,6 +681,9 @@ export interface RegExResult {
}
type LogLevel = "error" | "warn" | "info" | "verbose" | "debug";
/**
* Available cache providers
* */
export type CacheProvider = 'memory' | 'redis' | 'none';
// export type StrongCache = SubredditCacheConfig & {
@@ -640,17 +692,68 @@ export type CacheProvider = 'memory' | 'redis' | 'none';
export type StrongCache = {
authorTTL: number,
userNotesTTL: number,
wikiTTL: number
wikiTTL: number,
submissionTTL: number,
commentTTL: number,
filterCriteriaTTL: number,
provider: CacheOptions
}
/**
* Configure granular settings for a cache provider with this object
* */
export interface CacheOptions {
store: CacheProvider,
/**
* (`redis`) hostname
*
* @default "localhost"
* @examples ["localhost"]
* */
host?: string | undefined,
/**
* (`redis`) port to connect on
*
* @default 6379
* @examples [6379]
* */
port?: number | undefined,
/**
* (`redis`) the authentication passphrase (if enabled)
* */
auth_pass?: string | undefined,
/**
* (`redis`) the db number to use
*
* @default 0
* @examples [0]
* */
db?: number | undefined,
/**
* The default TTL, in seconds, for the cache provider.
*
* Can mostly be ignored since TTLs are defined for each cache object
*
* @default 60
* @examples [60]
* */
ttl?: number,
/**
* (`memory`) The maximum number of keys (unique cache calls) to store in cache
*
* When the maximum number of keys is reached the cache will being dropping the [least-recently-used](https://github.com/isaacs/node-lru-cache) key to keep the cache at `max` size.
*
* This will determine roughly how large in **RAM** each `memory` cache can be, based on how large your `window` criteria are. Consider this example:
*
* * all `window` criteria in a subreddit's rules are `"window": 100`
* * `"max": 500`
* * Maximum size of **each** memory cache will be `500 x 100 activities = 50,000 activities`
* * So the shared cache would be max 50k activities and
* * Every additional private cache (when a subreddit configures their cache separately) will also be max 50k activities
*
* @default 500
* @examples [500]
* */
max?: number
}
@@ -684,6 +787,9 @@ export interface NotificationContent {
export type NotificationEvents = (NotificationEventType[] | NotificationEventConfig)[];
export interface NotificationConfig {
/**
* A list of notification providers (Discord, etc..) to configure. Each object in the list is one provider. Multiple of the same provider can be provided but must have different names
* */
providers: NotificationProviders[],
events: NotificationEvents
}
@@ -699,83 +805,398 @@ export interface ManagerStateChangeOption {
suppressNotification?: boolean
}
/**
* Configuration for application-level settings IE for running the bot instance
*
* * 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`
* * To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`
* */
export interface OperatorJsonConfig {
/**
* Settings related to the user(s) running this ContextMod instance and information on the bot
* */
operator?: {
name?: string,
/**
* The name, or names, of the Reddit accounts, without prefix, that the operators of this bot uses.
*
* This is used for showing more information in the web interface IE show all logs/subreddits if even not a moderator.
*
* EX -- User is /u/FoxxMD then `"name": ["FoxxMD"]`
*
* * ENV => `OPERATOR` (if list, comma-delimited)
* * ARG => `--operator <name...>`
*
* @examples [["FoxxMD","AnotherUser"]]
* */
name?: string | string[],
/**
* A **public** name to display to users of the web interface. Use this to help moderators using your bot identify who is the operator in case they need to contact you.
*
* Leave undefined for no public name to be displayed.
*
* * ENV => `OPERATOR_DISPLAY`
* * ARG => `--operatorDisplay <name>`
*
* @examples ["Moderators of r/MySubreddit"]
* */
display?: string,
/**
* The name to use when identifying the bot. Defaults to name of the authenticated Reddit account IE `u/yourBotAccount`
*
* @examples ["u/yourBotAccount"]
* */
botName?: string,
},
/**
* The credentials required for the bot to interact with Reddit's API
*
* **Note:** Only `clientId` and `clientSecret` are required for initial setup (to use the oauth helper) **but ALL are required to properly run the bot.**
* */
credentials?: {
/**
* Client ID for your Reddit application
*
* * ENV => `CLIENT_ID`
* * ARG => `--clientId <id>`
*
* @examples ["f4b4df1c7b2"]
* */
clientId?: string,
/**
* Client Secret for your Reddit application
*
* * ENV => `CLIENT_SECRET`
* * ARG => `--clientSecret <id>`
*
* @examples ["34v5q1c56ub"]
* */
clientSecret?: string,
/**
* Redirect URI for your Reddit application
*
* Only required if running ContextMod with a web interface (and after using oauth helper)
*
* * ENV => `REDIRECT_URI`
* * ARG => `--redirectUri <uri>`
*
* @examples ["http://localhost:8085"]
* @format uri
* */
redirectUri?: string,
/**
* Access token retrieved from authenticating an account with your Reddit Application
*
* * ENV => `ACCESS_TOKEN`
* * ARG => `--accessToken <token>`
*
* @examples ["p75_1c467b2"]
* */
accessToken?: string,
/**
* Refresh token retrieved from authenticating an account with your Reddit Application
*
* * ENV => `REFRESH_TOKEN`
* * ARG => `--refreshToken <token>`
*
* @examples ["34_f1w1v4"]
* */
refreshToken?: string
},
/**
* Settings to configure 3rd party notifications for when ContextMod behavior occurs
* */
notifications?: NotificationConfig
/**
* Settings to configure global logging defaults
* */
logging?: {
/**
* The minimum log level to output. The log level set will output logs at its level **and all levels above it:**
*
* * `error`
* * `warn`
* * `info`
* * `verbose`
* * `debug`
*
* Note: `verbose` will display *a lot* of information on the status/result of run rules/checks/actions etc. which is very useful for testing configurations. Once your bot is stable changing the level to `info` will reduce log noise.
*
* * ENV => `LOG_LEVEL`
* * ARG => `--logLevel <level>`
*
* @default "verbose"
* @examples ["verbose"]
* */
level?: LogLevel,
/**
* The absolute path to a directory where rotating log files should be stored.
*
* * If not present or `null` no log files will be created
* * If `true` logs will be stored at `[working directory]/logs`
*
* * ENV => `LOG_DIR`
* * ARG => `--logDir [dir]`
*
* @examples ["/var/log/contextmod"]
* */
path?: string,
},
/**
* Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior
* */
snoowrap?: {
/**
* Proxy all requests to Reddit's API through this endpoint
*
* * ENV => `PROXY`
* * ARG => `--proxy <proxyEndpoint>`
*
* @examples ["http://localhost:4443"]
* */
proxy?: string,
/**
* Manually set the debug status for snoowrap
*
* When snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level
*
* * Set to `true` to always output
* * Set to `false` to never output
*
* If not present or `null` will be set based on `logLevel`
*
* * ENV => `SNOO_DEBUG`
* * ARG => `--snooDebug`
* */
debug?: boolean,
}
/**
* Settings related to bot behavior for subreddits it is managing
* */
subreddits?: {
/**
* Names of subreddits for bot to run on
*
* If not present or `null` bot will run on all subreddits it is a moderator of
*
* * ENV => `SUBREDDITS` (comma-separated)
* * ARG => `--subreddits <list...>`
*
* @examples [["mealtimevideos","programminghumor"]]
* */
names?: string[],
/**
* If `true` then all subreddits will run in dry run mode, overriding configurations
*
* * ENV => `DRYRUN`
* * ARG => `--dryRun`
*
* @default false
* @examples [false]
* */
dryRun?: boolean,
/**
* The default relative url to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`
*
* * ENV => `WIKI_CONFIG`
* * ARG => `--wikiConfig <path>`
*
* @default "botconfig/contextbot"
* @examples ["botconfig/contextbot"]
* */
wikiConfig?: string,
/**
* Interval, in seconds, to perform application heartbeat
*
* On heartbeat the application does several things:
*
* * Log output with current api rate remaining and other statistics
* * Tries to retrieve and parse configurations for any subreddits with invalid configuration state
* * Restarts any bots stopped/paused due to polling issues, general errors, or invalid configs (if new config is valid)
*
* * ENV => `HEARTBEAT`
* * ARG => `--heartbeat <sec>`
*
* @default 300
* @examples [300]
* */
heartbeatInterval?: number,
},
/**
* Settings related to default polling configurations for subreddits
* */
polling?: PollingDefaults & {
/**
* If set to `true` all subreddits polling unmoderated/modqueue with default polling settings will share a request to "r/mod"
* otherwise each subreddit will poll its own mod view
*
* * ENV => `SHARE_MOD`
* * ARG => `--shareMod`
*
* @default false
* */
sharedMod?: boolean,
limit?: number,
interval?: number,
},
/**
* Settings related to default configurations for queue behavior for subreddits
* */
queue?: {
/**
* Set the number of maximum concurrent workers any subreddit can use.
*
* Subreddits may define their own number of max workers in their config but the application will never allow any subreddit's max workers to be larger than the operator
*
* NOTE: Do not increase this unless you are certain you know what you are doing! The default is suitable for the majority of use cases.
*
* @default 1
* @examples [1]
* */
maxWorkers?: number,
},
/**
* Settings for the web interface
* */
web?: {
/**
* Whether the web server interface should be started
*
* In most cases this does not need to be specified as the application will automatically detect if it is possible to start it --
* use this to specify "cli only" behavior if you encounter errors with port/address or are paranoid
*
* * ENV => `WEB`
* * ARG => `node src/index.js run [interface]` -- interface can be `web` or `cli`
*
* @default true
* */
enabled?: boolean,
/**
* The port for the web interface
*
* * ENV => `PORT`
* * ARG => `--port <number>`
*
* @default 8085
* @examples [8085]
* */
port?: number,
/**
* Settings to configure the behavior of user sessions -- the session is what the web interface uses to identify logged in users.
* */
session?: {
/**
* The cache provider to use.
*
* The default should be sufficient for almost all use cases
*
* @default "memory"
* @examples ["memory"]
* */
provider?: 'memory' | 'redis' | CacheOptions,
/**
* The secret value used to encrypt session data
*
* If provider is persistent (redis) specifying a value here will ensure sessions are valid between application restarts
*
* When not present or `null` a random string is generated on application start
*
* @examples ["definitelyARandomString"]
* */
secret?: string,
}
/**
* The default log level to filter to in the web interface
*
* If not specified or `null` will be same as global `logLevel`
* */
logLevel?: LogLevel,
/**
* Maximum number of log statements to keep in memory for each subreddit
*
* @default 200
* @examples [200]
* */
maxLogs?: number,
}
// caching?: (SubredditCacheConfig & {
// provider?: CacheProvider | CacheOptions | undefined
// }) | CacheProvider | undefined
/**
* Settings to configure the default caching behavior for each suberddit
* */
caching?: {
/**
* Amount of time, in milliseconds, author activities (Comments/Submission) should be cached
* @examples [10000]
* @default 10000
* Amount of time, in seconds, author activity history (Comments/Submission) should be cached
*
* * ENV => `AUTHOR_TTL`
* * ARG => `--authorTTL <sec>`
* @examples [60]
* @default 60
* */
authorTTL?: number;
/**
* Amount of time, in milliseconds, wiki content pages should be cached
* @examples [300000]
* @default 300000
* Amount of time, in seconds, wiki content pages should be cached
* @examples [300]
* @default 300
* */
wikiTTL?: number;
/**
* Amount of time, in milliseconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached
* @examples [60000]
* @default 60000
* Amount of time, in seconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached
* @examples [300]
* @default 300
* */
userNotesTTL?: number;
/**
* Amount of time, in seconds, a submission should be cached
* @examples [60]
* @default 60
* */
submissionTTL?: number;
/**
* Amount of time, in seconds, a comment should be cached
* @examples [60]
* @default 60
* */
commentTTL?: number;
/**
* Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)
*
* This is especially useful if when polling high-volume comments and your checks rely on author/item filters
*
* @examples [60]
* @default 60
* */
filterCriteriaTTL?: number;
/**
* The cache provider and, optionally, a custom configuration for that provider
*
* If not present or `null` provider will be `memory`.
*
* To specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`
* */
provider?: CacheProvider | CacheOptions
} | CacheProvider
}
/**
* Settings related to managing heavy API usage.
* */
api?: {
/**
* When `api limit remaining` reaches this number the application will attempt to put heavy-usage subreddits in a **slow mode** where activity processed is slowed to one every 1.5 seconds until the api limit is reset.
*
* @default 250
* @examples [250]
* */
softLimit?: number,
/**
* When `api limit remaining` reaches this number the application will pause all event polling until the api limit is reset.
*
* @default 50
* @examples [50]
* */
hardLimit?: number,
}
}
export interface OperatorConfig extends OperatorJsonConfig {
operator: {
name?: string
name: string[]
display?: string,
botName?: string,
},
credentials: {
clientId: string,
@@ -804,6 +1225,9 @@ export interface OperatorConfig extends OperatorJsonConfig {
limit: number,
interval: number,
},
queue: {
maxWorkers: number,
},
web: {
enabled: boolean,
port: number,
@@ -814,12 +1238,7 @@ export interface OperatorConfig extends OperatorJsonConfig {
logLevel?: LogLevel,
maxLogs: number,
}
caching: {
authorTTL: number,
userNotesTTL: number,
wikiTTL: number
provider: CacheOptions
},
caching: StrongCache,
api: {
softLimit: number,
hardLimit: number,

View File

@@ -1,5 +1,5 @@
import {RecentActivityRuleJSONConfig} from "../Rule/RecentActivityRule";
import {RepeatActivityJSONConfig} from "../Rule/SubmissionRule/RepeatActivityRule";
import {RepeatActivityJSONConfig} from "../Rule/RepeatActivityRule";
import {AuthorRuleJSONConfig} from "../Rule/AuthorRule";
import {AttributionJSONConfig} from "../Rule/AttributionRule";
import {FlairActionJson} from "../Action/SubmissionAction/FlairAction";
@@ -12,9 +12,10 @@ import {UserNoteActionJson} from "../Action/UserNoteAction";
import {ApproveActionJson} from "../Action/ApproveAction";
import {BanActionJson} from "../Action/BanAction";
import {RegexRuleJSONConfig} from "../Rule/RegexRule";
import {MessageActionJson} from "../Action/MessageAction";
export type RuleJson = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | string;
export type RuleObjectJson = Exclude<RuleJson, string>
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | string;
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | string;
export type ActionObjectJson = Exclude<ActionJson, string>;

View File

@@ -322,27 +322,31 @@ export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
return removeUndefinedKeys(data) as OperatorJsonConfig;
}
export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
let subsVal = process.env.SUBREDDITS;
let subs;
if (subsVal !== undefined) {
subsVal = subsVal.trim();
if (subsVal.includes(',')) {
// try to parse using comma
subs = subsVal.split(',').map(x => x.trim()).filter(x => x !== '');
} else {
// otherwise try spaces
subs = subsVal.split(' ')
// remove any extraneous spaces
.filter(x => x !== ' ' && x !== '');
}
if (subs.length === 0) {
subs = undefined;
}
const parseListFromEnv = (val: string|undefined) => {
let listVals: undefined | string[];
if(val === undefined) {
return listVals;
}
const trimmedVal = val.trim();
if (trimmedVal.includes(',')) {
// try to parse using comma
listVals = trimmedVal.split(',').map(x => x.trim()).filter(x => x !== '');
} else {
// otherwise try spaces
listVals = trimmedVal.split(' ')
// remove any extraneous spaces
.filter(x => x !== ' ' && x !== '');
}
if (listVals.length === 0) {
return undefined;
}
return listVals;
}
export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
const data = {
operator: {
name: process.env.OPERATOR,
name: parseListFromEnv(process.env.OPERATOR),
display: process.env.OPERATOR_DISPLAY
},
credentials: {
@@ -353,7 +357,7 @@ export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
redirectUri: process.env.REDIRECT_URI,
},
subreddits: {
names: subs,
names: parseListFromEnv(process.env.SUBREDDITS),
wikiConfig: process.env.WIKI_CONFIG,
heartbeatInterval: process.env.HEARTBEAT !== undefined ? parseInt(process.env.HEARTBEAT) : undefined,
dryRun: parseBool(process.env.DRYRUN, undefined),
@@ -380,8 +384,7 @@ export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
},
caching: {
provider: {
// @ts-ignore
store: process.env.CACHING
store: process.env.CACHING as (CacheProvider | undefined)
},
authorTTL: process.env.AUTHOR_TTL !== undefined ? parseInt(process.env.AUTHOR_TTL) : undefined
},
@@ -462,8 +465,9 @@ export const parseOperatorConfigFromSources = async (args: any): Promise<Operato
export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): OperatorConfig => {
const {
operator: {
name,
display = 'Anonymous'
name = [],
display = 'Anonymous',
botName,
} = {},
credentials: {
clientId: ci,
@@ -495,30 +499,27 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
limit = 100,
interval = 30,
} = {},
caching = 'memory',
queue: {
maxWorkers = 1,
} = {},
caching,
api: {
softLimit = 250,
hardLimit = 50
} = {},
} = data;
let cache = {
...cacheTTLDefaults,
provider: {
store: 'memory',
...cacheOptDefaults
}
};
let cache: StrongCache;
if (typeof caching === 'string') {
if(caching === undefined) {
cache = {
...cacheTTLDefaults,
provider: {
store: caching as CacheProvider,
store: 'memory',
...cacheOptDefaults
},
...cacheTTLDefaults
}
};
} else if (typeof caching === 'object') {
} else {
const {provider, ...restConfig} = caching;
if (typeof provider === 'string') {
cache = {
@@ -545,8 +546,9 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
const config: OperatorConfig = {
operator: {
name,
display
name: typeof name === 'string' ? [name] : name,
display,
botName,
},
credentials: {
clientId: (ci as string),
@@ -581,13 +583,15 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
},
maxLogs,
},
// @ts-ignore
caching: cache,
polling: {
sharedMod,
limit,
interval,
},
queue: {
maxWorkers,
},
api: {
softLimit,
hardLimit

View File

@@ -1,3 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
//# sourceMappingURL=JsonConfig.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"JsonConfig.js","sourceRoot":"","sources":["JsonConfig.ts"],"names":[],"mappings":""}

View File

@@ -3,22 +3,24 @@ import {NotificationContent} from "../Common/interfaces";
class DiscordNotifier {
name: string
botName: string
type: string = 'Discord';
url: string;
constructor(name: string, url: string) {
constructor(name: string, botName: string, url: string) {
this.name = name;
this.url = url;
this.botName = botName;
}
handle(val: NotificationContent) {
async handle(val: NotificationContent) {
const h = new webhook.Webhook(this.url);
const hook = new webhook.MessageBuilder();
const {logLevel, title, footer, body = ''} = val;
hook.setName('RCB')
hook.setName(this.botName === 'ContextMod' ? 'ContextMod' : `(ContextMod) ${this.botName}`)
.setTitle(title)
.setDescription(body)
@@ -39,7 +41,7 @@ class DiscordNotifier {
break;
}
h.send(hook);
await h.send(hook);
}
}

View File

@@ -17,7 +17,7 @@ class NotificationManager {
subreddit: Subreddit;
name: string;
constructor(logger: Logger, subreddit: Subreddit, displayName: string, config?: NotificationConfig) {
constructor(logger: Logger, subreddit: Subreddit, displayName: string, botName: string, config?: NotificationConfig) {
this.logger = logger.child({leaf: 'Notifications'}, mergeArr);
this.subreddit = subreddit;
this.name = displayName;
@@ -27,7 +27,7 @@ class NotificationManager {
for (const p of providers) {
switch (p.type) {
case 'discord':
this.notifiers.push(new DiscordNotifier(p.name, p.url));
this.notifiers.push(new DiscordNotifier(p.name, botName, p.url));
break;
default:
this.logger.warn(`Notification provider type of ${p.type} not recognized.`);
@@ -64,7 +64,7 @@ class NotificationManager {
}
}
handle(name: NotificationEventType, title: string, body?: string, causedBy?: string, logLevel?: string) {
async handle(name: NotificationEventType, title: string, body?: string, causedBy?: string, logLevel?: string) {
if (this.notifiers.length === 0 || this.events.length === 0) {
return;
@@ -109,7 +109,7 @@ class NotificationManager {
this.logger.info(`Sending notification for ${name} to providers: ${notifiers.map(x => `${x.name} (${x.type})`).join(', ')}`);
for (const n of notifiers) {
n.handle({
await n.handle({
title: `${title} (${this.name})`,
body: body || '',
footer: footer.length > 0 ? footer.join('\n') : undefined,

View File

@@ -415,7 +415,7 @@ export interface AttributionOptions extends AttributionConfig, RuleOptions {
/**
* Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered
*
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* Available data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):
*
* ```
* triggeredDomainCount => Number of domains that met the threshold

View File

@@ -322,7 +322,7 @@ export interface HistoryOptions extends HistoryConfig, RuleOptions {
/**
* Aggregates an Author's submission and comment history. Rule can be triggered on count/percent of total (for either or both comment/sub totals) as well as comment OP total.
*
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* Available data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):
*
* ```
* activityTotal => Total number of activities

View File

@@ -212,7 +212,7 @@ export interface RecentActivityRuleOptions extends RecentActivityConfig, RuleOpt
/**
* Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds
*
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* Available data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):
*
* ```
* summary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...

View File

@@ -379,7 +379,7 @@ export interface RegexRuleOptions extends RegexConfig, RuleOptions {
*
* Optionally, specify a `window` of the User's history to additionally test against
*
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* Available data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):
*
* */
export interface RegexRuleJSONConfig extends RegexConfig, RuleJSONConfig {

View File

@@ -1,13 +1,12 @@
import {SubmissionRule, SubmissionRuleJSONConfig} from "./index";
import {RuleOptions, RuleResult} from "../index";
import {Rule, RuleJSONConfig, RuleOptions, RuleResult} from "./index";
import {Comment} from "snoowrap";
import {
activityWindowText,
comparisonTextOp, FAIL, isExternalUrlSubmission, isRedditMedia,
parseGenericValueComparison, parseSubredditName,
parseUsableLinkIdentifier as linkParser, PASS
} from "../../util";
import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../../Common/interfaces";
} from "../util";
import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../Common/interfaces";
import Submission from "snoowrap/dist/objects/Submission";
import dayjs from "dayjs";
import Fuse from 'fuse.js'
@@ -45,7 +44,7 @@ const fuzzyOptions = {
distance: 15
};
export class RepeatActivityRule extends SubmissionRule {
export class RepeatActivityRule extends Rule {
threshold: string;
window: ActivityWindowType;
gapAllowance?: number;
@@ -95,11 +94,10 @@ export class RepeatActivityRule extends SubmissionRule {
}
}
async process(item: Submission): Promise<[boolean, RuleResult]> {
const referenceUrl = await item.url;
if (referenceUrl === undefined && this.useSubmissionAsReference) {
this.logger.warn(`Rule not triggered because useSubmissionAsReference=true but submission is not a link`);
return Promise.resolve([false, this.getResult(false)]);
async process(item: Submission|Comment): Promise<[boolean, RuleResult]> {
let referenceUrl;
if(item instanceof Submission && this.useSubmissionAsReference) {
referenceUrl = await item.url;
}
let filterFunc = (x: any) => true;
@@ -196,7 +194,7 @@ export class RepeatActivityRule extends SubmissionRule {
let referenceSubmissions = identifierGroupedActivities.get(identifier);
if(referenceSubmissions === undefined && isExternalUrlSubmission(item)) {
// if external url sub then try by title
identifier = item.title;
identifier = (item as Submission).title;
referenceSubmissions = identifierGroupedActivities.get(identifier);
if(referenceSubmissions === undefined) {
// didn't get by title so go back to url since that's the default
@@ -354,7 +352,7 @@ export interface RepeatActivityOptions extends RepeatActivityConfig, RuleOptions
/**
* Checks a user's history for Submissions with identical content
*
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
* Available data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):
*
* ```
* count => Total number of repeat Submissions
@@ -362,7 +360,7 @@ export interface RepeatActivityOptions extends RepeatActivityConfig, RuleOptions
* url => Url of the submission that triggered the rule
* ```
* */
export interface RepeatActivityJSONConfig extends RepeatActivityConfig, SubmissionRuleJSONConfig {
export interface RepeatActivityJSONConfig extends RepeatActivityConfig, RuleJSONConfig {
kind: 'repeatActivity'
}

View File

@@ -1,5 +1,5 @@
import {RecentActivityRule, RecentActivityRuleJSONConfig} from "./RecentActivityRule";
import RepeatActivityRule, {RepeatActivityJSONConfig} from "./SubmissionRule/RepeatActivityRule";
import RepeatActivityRule, {RepeatActivityJSONConfig} from "./RepeatActivityRule";
import {Rule, RuleJSONConfig} from "./index";
import AuthorRule, {AuthorRuleJSONConfig} from "./AuthorRule";
import {AttributionJSONConfig, AttributionRule} from "./AttributionRule";

View File

@@ -1,8 +0,0 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SubmissionRule = void 0;
const index_1 = require("../index");
class SubmissionRule extends index_1.Rule {
}
exports.SubmissionRule = SubmissionRule;
//# sourceMappingURL=index.js.map

View File

@@ -1 +0,0 @@
{"version":3,"file":"index.js","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":";;;AAAA,oCAAqD;AAErD,MAAsB,cAAe,SAAQ,YAAI;CAEhD;AAFD,wCAEC"}

View File

@@ -4,7 +4,6 @@ import {Logger} from "winston";
import {findResultByPremise, mergeArr} from "../util";
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
import {ChecksActivityState, TypedActivityStates} from "../Common/interfaces";
import {isItem} from "../Utils/SnoowrapUtils";
import Author, {AuthorOptions} from "../Author/Author";
export interface RuleOptions {
@@ -83,7 +82,7 @@ export abstract class Rule implements IRule, Triggerable {
this.logger.debug(`Returning existing result of ${existingResult.triggered ? '✔️' : '❌'}`);
return Promise.resolve([existingResult.triggered, {...existingResult, name: this.name}]);
}
const [itemPass, crit] = isItem(item, this.itemIs, this.logger);
const itemPass = await this.resources.testItemCriteria(item, this.itemIs);
if (!itemPass) {
this.logger.verbose(`(Skipped) Item did not pass 'itemIs' test`);
return Promise.resolve([null, this.getResult(null, {result: `Item did not pass 'itemIs' test`})]);

View File

@@ -148,6 +148,7 @@
"type": "boolean"
},
"op": {
"description": "Is this Comment Author also the Author of the Submission this comment is in?",
"type": "boolean"
},
"removed": {
@@ -158,6 +159,13 @@
},
"stickied": {
"type": "boolean"
},
"submissionState": {
"description": "A list of SubmissionState attributes to test the Submission this comment is in",
"items": {
"$ref": "#/definitions/SubmissionState"
},
"type": "array"
}
},
"type": "object"
@@ -186,6 +194,12 @@
"is_self": {
"type": "boolean"
},
"link_flair_css_class": {
"type": "string"
},
"link_flair_text": {
"type": "string"
},
"locked": {
"type": "boolean"
},
@@ -207,6 +221,10 @@
},
"stickied": {
"type": "boolean"
},
"title": {
"description": "A valid regular expression to match against the title of the submission",
"type": "string"
}
},
"type": "object"
@@ -278,6 +296,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
@@ -303,6 +329,7 @@
"comment",
"flair",
"lock",
"message",
"remove",
"report",
"usernote"

View File

@@ -117,6 +117,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
@@ -276,7 +284,7 @@
"type": "object"
},
"AttributionJSONConfig": {
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -593,6 +601,14 @@
"minimum": 1,
"type": "number"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"footer": {
"anyOf": [
{
@@ -605,7 +621,7 @@
"type": "string"
}
],
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"itemIs": {
"anyOf": [
@@ -671,26 +687,53 @@
"type": "object"
},
"CacheOptions": {
"description": "Configure granular settings for a cache provider with this object",
"properties": {
"auth_pass": {
"description": "(`redis`) the authentication passphrase (if enabled)",
"type": "string"
},
"db": {
"default": 0,
"description": "(`redis`) the db number to use",
"examples": [
0
],
"type": "number"
},
"host": {
"default": "localhost",
"description": "(`redis`) hostname",
"examples": [
"localhost"
],
"type": "string"
},
"max": {
"default": 500,
"description": "(`memory`) The maximum number of keys (unique cache calls) to store in cache\n\nWhen the maximum number of keys is reached the cache will being dropping the [least-recently-used](https://github.com/isaacs/node-lru-cache) key to keep the cache at `max` size.\n\nThis will determine roughly how large in **RAM** each `memory` cache can be, based on how large your `window` criteria are. Consider this example:\n\n* all `window` criteria in a subreddit's rules are `\"window\": 100`\n* `\"max\": 500`\n* Maximum size of **each** memory cache will be `500 x 100 activities = 50,000 activities`\n * So the shared cache would be max 50k activities and\n * Every additional private cache (when a subreddit configures their cache separately) will also be max 50k activities",
"examples": [
500
],
"type": "number"
},
"port": {
"default": 6379,
"description": "(`redis`) port to connect on",
"examples": [
6379
],
"type": "number"
},
"store": {
"$ref": "#/definitions/CacheProvider"
},
"ttl": {
"default": 60,
"description": "The default TTL, in seconds, for the cache provider.\n\nCan mostly be ignored since TTLs are defined for each cache object",
"examples": [
60
],
"type": "number"
}
},
@@ -700,6 +743,7 @@
"type": "object"
},
"CacheProvider": {
"description": "Available cache providers",
"enum": [
"memory",
"none",
@@ -730,7 +774,7 @@
]
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"type": "string"
},
"distinguish": {
@@ -746,6 +790,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"footer": {
"anyOf": [
{
@@ -758,7 +810,7 @@
"type": "string"
}
],
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"itemIs": {
"anyOf": [
@@ -849,6 +901,9 @@
{
"$ref": "#/definitions/BanActionJson"
},
{
"$ref": "#/definitions/MessageActionJson"
},
{
"type": "string"
}
@@ -876,6 +931,10 @@
}
]
},
"cacheUserResult": {
"$ref": "#/definitions/UserResultCacheOptions",
"description": "Cache the result of this check based on the comment author and the submission id\n\nThis is useful in this type of scenario:\n\n1. This check is configured to run on comments for specific submissions with high volume activity\n2. The rules being run are not dependent on the content of the comment\n3. The rule results are not likely to change while cache is valid"
},
"condition": {
"default": "AND",
"description": "Under what condition should a set of run `Rule` objects be considered \"successful\"?\n\nIf `OR` then **any** triggered `Rule` object results in success.\n\nIf `AND` then **all** `Rule` objects must be triggered to result in success.",
@@ -902,6 +961,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "Should this check be run by the bot?",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"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}]]",
"items": {
@@ -994,6 +1061,7 @@
"type": "boolean"
},
"op": {
"description": "Is this Comment Author also the Author of the Submission this comment is in?",
"type": "boolean"
},
"removed": {
@@ -1004,6 +1072,13 @@
},
"stickied": {
"type": "boolean"
},
"submissionState": {
"description": "A list of SubmissionState attributes to test the Submission this comment is in",
"items": {
"$ref": "#/definitions/SubmissionState"
},
"type": "array"
}
},
"type": "object"
@@ -1121,6 +1196,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
@@ -1211,7 +1294,7 @@
"type": "object"
},
"HistoryJSONConfig": {
"description": "Aggregates an Author's submission and comment history. Rule can be triggered on count/percent of total (for either or both comment/sub totals) as well as comment OP total.\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nactivityTotal => Total number of activities\nsubmissionTotal => Total number of submissions\ncommentTotal => Total number of comments\nopTotal => Total number of comments as OP\nthresholdSummary => A text summary of the first Criteria triggered with totals/percentages\ncriteria => The ThresholdCriteria object\nwindow => A text summary of the range of Activities considered (# of Items if number, time range if Duration)\n```",
"description": "Aggregates an Author's submission and comment history. Rule can be triggered on count/percent of total (for either or both comment/sub totals) as well as comment OP total.\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nactivityTotal => Total number of activities\nsubmissionTotal => Total number of submissions\ncommentTotal => Total number of comments\nopTotal => Total number of comments as OP\nthresholdSummary => A text summary of the first Criteria triggered with totals/percentages\ncriteria => The ThresholdCriteria object\nwindow => A text summary of the range of Activities considered (# of Items if number, time range if Duration)\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -1342,6 +1425,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
@@ -1380,6 +1471,111 @@
],
"type": "object"
},
"MessageActionJson": {
"description": "Send a private message to the Author of the Activity.",
"properties": {
"asSubreddit": {
"description": "Should this message be sent from modmail (as the subreddit) or as the bot user?",
"type": "boolean"
},
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
"description": "If present then these Author criteria are checked before running the Action. If criteria fails then the Action is not run.",
"examples": [
{
"include": [
{
"flairText": [
"Contributor",
"Veteran"
]
},
{
"isMod": true
}
]
}
]
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"type": "string"
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
"examples": [
false,
true
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"footer": {
"anyOf": [
{
"enum": [
false
],
"type": "boolean"
},
{
"type": "string"
}
],
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"itemIs": {
"anyOf": [
{
"items": {
"$ref": "#/definitions/SubmissionState"
},
"type": "array"
},
{
"items": {
"$ref": "#/definitions/CommentState"
},
"type": "array"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the Action.\n\nIf any set of criteria passes the Action will be run."
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"message"
],
"type": "string"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"examples": [
"myDescriptiveAction"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"title": {
"description": "The title of the message\n\nIf not specified will be defaulted to `Concerning your [Submission/Comment]`",
"type": "string"
}
},
"required": [
"asSubreddit",
"content",
"kind"
],
"type": "object"
},
"NotificationConfig": {
"properties": {
"events": {
@@ -1405,6 +1601,7 @@
"type": "array"
},
"providers": {
"description": "A list of notification providers (Discord, etc..) to configure. Each object in the list is one provider. Multiple of the same provider can be provided but must have different names",
"items": {
"$ref": "#/definitions/DiscordProviderConfig"
},
@@ -1491,7 +1688,7 @@
"type": "object"
},
"RecentActivityRuleJSONConfig": {
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -1699,7 +1896,7 @@
"type": "object"
},
"RegexRuleJSONConfig": {
"description": "Test a (list of) Regular Expression against the contents or title of an Activity\n\nOptionally, specify a `window` of the User's history to additionally test against\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):",
"description": "Test a (list of) Regular Expression against the contents or title of an Activity\n\nOptionally, specify a `window` of the User's history to additionally test against\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -1816,6 +2013,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
@@ -1855,7 +2060,7 @@
"type": "object"
},
"RepeatActivityJSONConfig": {
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -2014,7 +2219,7 @@
]
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"examples": [
"This is the content of a comment/report/usernote",
"this is **bold** markdown text",
@@ -2031,6 +2236,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
@@ -2196,6 +2409,9 @@
{
"$ref": "#/definitions/BanActionJson"
},
{
"$ref": "#/definitions/MessageActionJson"
},
{
"type": "string"
}
@@ -2249,6 +2465,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "Should this check be run by the bot?",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"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}]]",
"items": {
@@ -2340,6 +2564,12 @@
"is_self": {
"type": "boolean"
},
"link_flair_css_class": {
"type": "string"
},
"link_flair_text": {
"type": "string"
},
"locked": {
"type": "boolean"
},
@@ -2361,6 +2591,10 @@
},
"stickied": {
"type": "boolean"
},
"title": {
"description": "A valid regular expression to match against the title of the submission",
"type": "string"
}
},
"type": "object"
@@ -2375,6 +2609,22 @@
],
"type": "number"
},
"commentTTL": {
"default": 60,
"description": "Amount of time, in seconds, a comment should be cached",
"examples": [
60
],
"type": "number"
},
"filterCriteriaTTL": {
"default": 60,
"description": "Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)\n\nThis is especially useful if when polling high-volume comments and your checks rely on author/item filters",
"examples": [
60
],
"type": "number"
},
"provider": {
"anyOf": [
{
@@ -2390,6 +2640,14 @@
}
]
},
"submissionTTL": {
"default": 60,
"description": "Amount of time, in seconds, a submission should be cached",
"examples": [
60
],
"type": "number"
},
"userNotesTTL": {
"default": 300,
"description": "Amount of time, in milliseconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached",
@@ -2440,7 +2698,7 @@
]
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"examples": [
"This is the content of a comment/report/usernote",
"this is **bold** markdown text",
@@ -2457,6 +2715,14 @@
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"itemIs": {
"anyOf": [
{
@@ -2539,6 +2805,23 @@
"type"
],
"type": "object"
},
"UserResultCacheOptions": {
"description": "Cache the result of this check based on the comment author and the submission id\n\nThis is useful in this type of scenario:\n\n1. This check is configured to run on comments for specific submissions with high volume activity\n2. The rules being run are not dependent on the content of the comment\n3. The rule results are not likely to change while cache is valid",
"properties": {
"enable": {
"type": "boolean"
},
"ttl": {
"default": 60,
"description": "The amount of time, in seconds, to cache this result",
"examples": [
60
],
"type": "number"
}
},
"type": "object"
}
},
"properties": {
@@ -2583,7 +2866,7 @@
}
],
"default": "undefined",
"description": "Customize the footer for Actions that send replies (Comment/Ban). **This sets the default value for all Actions without `footer` specified in their configuration.**\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
"description": "Customize the footer for Actions that send replies (Comment/Ban). **This sets the default value for all Actions without `footer` specified in their configuration.**\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"nickname": {
"type": "string"
@@ -2609,6 +2892,20 @@
]
},
"type": "array"
},
"queue": {
"properties": {
"maxWorkers": {
"default": 1,
"description": "The maximum number of events that can be processed simultaneously.\n\n**Do not modify this setting unless you know what you are doing.** The default of `1` is suitable for the majority of use-cases.\n\nRaising the max above `1` could be useful if you require very fast response time to short bursts of high-volume events. However logs may become unreadable as many events are processed at the same time. Additionally, any events that depend on past actions from your bot may not be processed correctly given the concurrent nature of this use case.\n\n**Note:** Max workers are also enforced at the operator level so a subreddit cannot raise their max above what is specified by the operator.",
"examples": [
1
],
"minimum": 1,
"type": "number"
}
},
"type": "object"
}
},
"required": [

View File

@@ -2,26 +2,53 @@
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"CacheOptions": {
"description": "Configure granular settings for a cache provider with this object",
"properties": {
"auth_pass": {
"description": "(`redis`) the authentication passphrase (if enabled)",
"type": "string"
},
"db": {
"default": 0,
"description": "(`redis`) the db number to use",
"examples": [
0
],
"type": "number"
},
"host": {
"default": "localhost",
"description": "(`redis`) hostname",
"examples": [
"localhost"
],
"type": "string"
},
"max": {
"default": 500,
"description": "(`memory`) The maximum number of keys (unique cache calls) to store in cache\n\nWhen the maximum number of keys is reached the cache will being dropping the [least-recently-used](https://github.com/isaacs/node-lru-cache) key to keep the cache at `max` size.\n\nThis will determine roughly how large in **RAM** each `memory` cache can be, based on how large your `window` criteria are. Consider this example:\n\n* all `window` criteria in a subreddit's rules are `\"window\": 100`\n* `\"max\": 500`\n* Maximum size of **each** memory cache will be `500 x 100 activities = 50,000 activities`\n * So the shared cache would be max 50k activities and\n * Every additional private cache (when a subreddit configures their cache separately) will also be max 50k activities",
"examples": [
500
],
"type": "number"
},
"port": {
"default": 6379,
"description": "(`redis`) port to connect on",
"examples": [
6379
],
"type": "number"
},
"store": {
"$ref": "#/definitions/CacheProvider"
},
"ttl": {
"default": 60,
"description": "The default TTL, in seconds, for the cache provider.\n\nCan mostly be ignored since TTLs are defined for each cache object",
"examples": [
60
],
"type": "number"
}
},
@@ -31,6 +58,7 @@
"type": "object"
},
"CacheProvider": {
"description": "Available cache providers",
"enum": [
"memory",
"none",
@@ -85,6 +113,7 @@
"type": "array"
},
"providers": {
"description": "A list of notification providers (Discord, etc..) to configure. Each object in the list is one provider. Multiple of the same provider can be provided but must have different names",
"items": {
"$ref": "#/definitions/DiscordProviderConfig"
},
@@ -150,97 +179,148 @@
"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`",
"properties": {
"api": {
"description": "Settings related to managing heavy API usage.",
"properties": {
"hardLimit": {
"default": 50,
"description": "When `api limit remaining` reaches this number the application will pause all event polling until the api limit is reset.",
"examples": [
50
],
"type": "number"
},
"softLimit": {
"default": 250,
"description": "When `api limit remaining` reaches this number the application will attempt to put heavy-usage subreddits in a **slow mode** where activity processed is slowed to one every 1.5 seconds until the api limit is reset.",
"examples": [
250
],
"type": "number"
}
},
"type": "object"
},
"caching": {
"anyOf": [
{
"properties": {
"authorTTL": {
"default": 10000,
"description": "Amount of time, in milliseconds, author activities (Comments/Submission) should be cached",
"examples": [
10000
],
"type": "number"
},
"provider": {
"anyOf": [
{
"$ref": "#/definitions/CacheOptions"
},
{
"enum": [
"memory",
"none",
"redis"
],
"type": "string"
}
]
},
"userNotesTTL": {
"default": 60000,
"description": "Amount of time, in milliseconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached",
"examples": [
60000
],
"type": "number"
},
"wikiTTL": {
"default": 300000,
"description": "Amount of time, in milliseconds, wiki content pages should be cached",
"examples": [
300000
],
"type": "number"
}
},
"type": "object"
},
{
"enum": [
"memory",
"none",
"redis"
"description": "Settings to configure the default caching behavior for each suberddit",
"properties": {
"authorTTL": {
"default": 60,
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
"examples": [
60
],
"type": "string"
"type": "number"
},
"commentTTL": {
"default": 60,
"description": "Amount of time, in seconds, a comment should be cached",
"examples": [
60
],
"type": "number"
},
"filterCriteriaTTL": {
"default": 60,
"description": "Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)\n\nThis is especially useful if when polling high-volume comments and your checks rely on author/item filters",
"examples": [
60
],
"type": "number"
},
"provider": {
"anyOf": [
{
"$ref": "#/definitions/CacheOptions"
},
{
"enum": [
"memory",
"none",
"redis"
],
"type": "string"
}
],
"description": "The cache provider and, optionally, a custom configuration for that provider\n\nIf not present or `null` provider will be `memory`.\n\nTo specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`"
},
"submissionTTL": {
"default": 60,
"description": "Amount of time, in seconds, a submission should be cached",
"examples": [
60
],
"type": "number"
},
"userNotesTTL": {
"default": 300,
"description": "Amount of time, in seconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached",
"examples": [
300
],
"type": "number"
},
"wikiTTL": {
"default": 300,
"description": "Amount of time, in seconds, wiki content pages should be cached",
"examples": [
300
],
"type": "number"
}
]
},
"type": "object"
},
"credentials": {
"description": "The credentials required for the bot to interact with Reddit's API\n\n**Note:** Only `clientId` and `clientSecret` are required for initial setup (to use the oauth helper) **but ALL are required to properly run the bot.**",
"properties": {
"accessToken": {
"description": "Access token retrieved from authenticating an account with your Reddit Application\n\n* ENV => `ACCESS_TOKEN`\n* ARG => `--accessToken <token>`",
"examples": [
"p75_1c467b2"
],
"type": "string"
},
"clientId": {
"description": "Client ID for your Reddit application\n\n* ENV => `CLIENT_ID`\n* ARG => `--clientId <id>`",
"examples": [
"f4b4df1c7b2"
],
"type": "string"
},
"clientSecret": {
"description": "Client Secret for your Reddit application\n\n* ENV => `CLIENT_SECRET`\n* ARG => `--clientSecret <id>`",
"examples": [
"34v5q1c56ub"
],
"type": "string"
},
"redirectUri": {
"description": "Redirect URI for your Reddit application\n\nOnly required if running ContextMod with a web interface (and after using oauth helper)\n\n* ENV => `REDIRECT_URI`\n* ARG => `--redirectUri <uri>`",
"examples": [
"http://localhost:8085"
],
"format": "uri",
"type": "string"
},
"refreshToken": {
"description": "Refresh token retrieved from authenticating an account with your Reddit Application\n\n* ENV => `REFRESH_TOKEN`\n* ARG => `--refreshToken <token>`",
"examples": [
"34_f1w1v4"
],
"type": "string"
}
},
"type": "object"
},
"logging": {
"description": "Settings to configure global logging defaults",
"properties": {
"level": {
"default": "verbose",
"description": "The minimum log level to output. The log level set will output logs at its level **and all levels above it:**\n\n * `error`\n * `warn`\n * `info`\n * `verbose`\n * `debug`\n\n Note: `verbose` will display *a lot* of information on the status/result of run rules/checks/actions etc. which is very useful for testing configurations. Once your bot is stable changing the level to `info` will reduce log noise.\n\n * ENV => `LOG_LEVEL`\n * ARG => `--logLevel <level>`",
"enum": [
"debug",
"error",
@@ -248,24 +328,61 @@
"verbose",
"warn"
],
"examples": [
"verbose"
],
"type": "string"
},
"path": {
"description": "The absolute path to a directory where rotating log files should be stored.\n\n* If not present or `null` no log files will be created\n* If `true` logs will be stored at `[working directory]/logs`\n\n* ENV => `LOG_DIR`\n* ARG => `--logDir [dir]`",
"examples": [
"/var/log/contextmod"
],
"type": "string"
}
},
"type": "object"
},
"notifications": {
"$ref": "#/definitions/NotificationConfig"
"$ref": "#/definitions/NotificationConfig",
"description": "Settings to configure 3rd party notifications for when ContextMod behavior occurs"
},
"operator": {
"description": "Settings related to the user(s) running this ContextMod instance and information on the bot",
"properties": {
"botName": {
"description": "The name to use when identifying the bot. Defaults to name of the authenticated Reddit account IE `u/yourBotAccount`",
"examples": [
"u/yourBotAccount"
],
"type": "string"
},
"display": {
"description": "A **public** name to display to users of the web interface. Use this to help moderators using your bot identify who is the operator in case they need to contact you.\n\nLeave undefined for no public name to be displayed.\n\n* ENV => `OPERATOR_DISPLAY`\n* ARG => `--operatorDisplay <name>`",
"examples": [
"Moderators of r/MySubreddit"
],
"type": "string"
},
"name": {
"type": "string"
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "The name, or names, of the Reddit accounts, without prefix, that the operators of this bot uses.\n\nThis is used for showing more information in the web interface IE show all logs/subreddits if even not a moderator.\n\nEX -- User is /u/FoxxMD then `\"name\": [\"FoxxMD\"]`\n\n* ENV => `OPERATOR` (if list, comma-delimited)\n* ARG => `--operator <name...>`",
"examples": [
[
"FoxxMD",
"AnotherUser"
]
]
}
},
"type": "object"
@@ -277,57 +394,101 @@
},
{
"properties": {
"interval": {
"type": "number"
},
"limit": {
"type": "number"
},
"sharedMod": {
"default": false,
"description": "If set to `true` all subreddits polling unmoderated/modqueue with default polling settings will share a request to \"r/mod\"\notherwise each subreddit will poll its own mod view\n\n* ENV => `SHARE_MOD`\n* ARG => `--shareMod`",
"type": "boolean"
}
},
"type": "object"
}
]
],
"description": "Settings related to default polling configurations for subreddits"
},
"queue": {
"description": "Settings related to default configurations for queue behavior for subreddits",
"properties": {
"maxWorkers": {
"default": 1,
"description": "Set the number of maximum concurrent workers any subreddit can use.\n\nSubreddits may define their own number of max workers in their config but the application will never allow any subreddit's max workers to be larger than the operator\n\nNOTE: Do not increase this unless you are certain you know what you are doing! The default is suitable for the majority of use cases.",
"examples": [
1
],
"type": "number"
}
},
"type": "object"
},
"snoowrap": {
"description": "Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior",
"properties": {
"debug": {
"description": "Manually set the debug status for snoowrap\n\nWhen snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level\n\n* Set to `true` to always output\n* Set to `false` to never output\n\nIf not present or `null` will be set based on `logLevel`\n\n* ENV => `SNOO_DEBUG`\n* ARG => `--snooDebug`",
"type": "boolean"
},
"proxy": {
"description": "Proxy all requests to Reddit's API through this endpoint\n\n* ENV => `PROXY`\n* ARG => `--proxy <proxyEndpoint>`",
"examples": [
"http://localhost:4443"
],
"type": "string"
}
},
"type": "object"
},
"subreddits": {
"description": "Settings related to bot behavior for subreddits it is managing",
"properties": {
"dryRun": {
"default": false,
"description": "If `true` then all subreddits will run in dry run mode, overriding configurations\n\n* ENV => `DRYRUN`\n* ARG => `--dryRun`",
"examples": [
false
],
"type": "boolean"
},
"heartbeatInterval": {
"default": 300,
"description": "Interval, in seconds, to perform application heartbeat\n\nOn heartbeat the application does several things:\n\n* Log output with current api rate remaining and other statistics\n* Tries to retrieve and parse configurations for any subreddits with invalid configuration state\n* Restarts any bots stopped/paused due to polling issues, general errors, or invalid configs (if new config is valid)\n\n* ENV => `HEARTBEAT`\n* ARG => `--heartbeat <sec>`",
"examples": [
300
],
"type": "number"
},
"names": {
"description": "Names of subreddits for bot to run on\n\nIf not present or `null` bot will run on all subreddits it is a moderator of\n\n* ENV => `SUBREDDITS` (comma-separated)\n* ARG => `--subreddits <list...>`",
"examples": [
[
"mealtimevideos",
"programminghumor"
]
],
"items": {
"type": "string"
},
"type": "array"
},
"wikiConfig": {
"default": "botconfig/contextbot",
"description": "The default relative url to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`\n\n* ENV => `WIKI_CONFIG`\n* ARG => `--wikiConfig <path>`",
"examples": [
"botconfig/contextbot"
],
"type": "string"
}
},
"type": "object"
},
"web": {
"description": "Settings for the web interface",
"properties": {
"enabled": {
"default": true,
"description": "Whether the web server interface should be started\n\nIn most cases this does not need to be specified as the application will automatically detect if it is possible to start it --\nuse this to specify \"cli only\" behavior if you encounter errors with port/address or are paranoid\n\n* ENV => `WEB`\n* ARG => `node src/index.js run [interface]` -- interface can be `web` or `cli`",
"type": "boolean"
},
"logLevel": {
"description": "The default log level to filter to in the web interface\n\nIf not specified or `null` will be same as global `logLevel`",
"enum": [
"debug",
"error",
@@ -338,12 +499,23 @@
"type": "string"
},
"maxLogs": {
"default": 200,
"description": "Maximum number of log statements to keep in memory for each subreddit",
"examples": [
200
],
"type": "number"
},
"port": {
"default": 8085,
"description": "The port for the web interface\n\n* ENV => `PORT`\n* ARG => `--port <number>`",
"examples": [
8085
],
"type": "number"
},
"session": {
"description": "Settings to configure the behavior of user sessions -- the session is what the web interface uses to identify logged in users.",
"properties": {
"provider": {
"anyOf": [
@@ -357,9 +529,18 @@
],
"type": "string"
}
],
"default": "memory",
"description": "The cache provider to use.\n\nThe default should be sufficient for almost all use cases",
"examples": [
"memory"
]
},
"secret": {
"description": "The secret value used to encrypt session data\n\nIf provider is persistent (redis) specifying a value here will ensure sessions are valid between application restarts\n\nWhen not present or `null` a random string is generated on application start",
"examples": [
"definitelyARandomString"
],
"type": "string"
}
},

View File

@@ -230,7 +230,7 @@
"type": "object"
},
"AttributionJSONConfig": {
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -532,6 +532,7 @@
"type": "boolean"
},
"op": {
"description": "Is this Comment Author also the Author of the Submission this comment is in?",
"type": "boolean"
},
"removed": {
@@ -542,6 +543,13 @@
},
"stickied": {
"type": "boolean"
},
"submissionState": {
"description": "A list of SubmissionState attributes to test the Submission this comment is in",
"items": {
"$ref": "#/definitions/SubmissionState"
},
"type": "array"
}
},
"type": "object"
@@ -650,7 +658,7 @@
"type": "object"
},
"HistoryJSONConfig": {
"description": "Aggregates an Author's submission and comment history. Rule can be triggered on count/percent of total (for either or both comment/sub totals) as well as comment OP total.\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nactivityTotal => Total number of activities\nsubmissionTotal => Total number of submissions\ncommentTotal => Total number of comments\nopTotal => Total number of comments as OP\nthresholdSummary => A text summary of the first Criteria triggered with totals/percentages\ncriteria => The ThresholdCriteria object\nwindow => A text summary of the range of Activities considered (# of Items if number, time range if Duration)\n```",
"description": "Aggregates an Author's submission and comment history. Rule can be triggered on count/percent of total (for either or both comment/sub totals) as well as comment OP total.\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nactivityTotal => Total number of activities\nsubmissionTotal => Total number of submissions\ncommentTotal => Total number of comments\nopTotal => Total number of comments as OP\nthresholdSummary => A text summary of the first Criteria triggered with totals/percentages\ncriteria => The ThresholdCriteria object\nwindow => A text summary of the range of Activities considered (# of Items if number, time range if Duration)\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -751,7 +759,7 @@
"type": "object"
},
"RecentActivityRuleJSONConfig": {
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -959,7 +967,7 @@
"type": "object"
},
"RegexRuleJSONConfig": {
"description": "Test a (list of) Regular Expression against the contents or title of an Activity\n\nOptionally, specify a `window` of the User's history to additionally test against\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):",
"description": "Test a (list of) Regular Expression against the contents or title of an Activity\n\nOptionally, specify a `window` of the User's history to additionally test against\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -1046,7 +1054,7 @@
"type": "object"
},
"RepeatActivityJSONConfig": {
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -1240,6 +1248,12 @@
"is_self": {
"type": "boolean"
},
"link_flair_css_class": {
"type": "string"
},
"link_flair_text": {
"type": "string"
},
"locked": {
"type": "boolean"
},
@@ -1261,6 +1275,10 @@
},
"stickied": {
"type": "boolean"
},
"title": {
"description": "A valid regular expression to match against the title of the submission",
"type": "string"
}
},
"type": "object"

View File

@@ -207,7 +207,7 @@
"type": "object"
},
"AttributionJSONConfig": {
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"description": "Aggregates all of the domain/media accounts attributed to an author's Submission history. If any domain is over the threshold the rule is triggered\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\ntriggeredDomainCount => Number of domains that met the threshold\nactivityTotal => Number of Activities considered from window\nwindow => The date range of the Activities considered\nlargestCount => The count from the largest aggregated domain\nlargestPercentage => The percentage of Activities the largest aggregated domain comprises\nsmallestCount => The count from the smallest aggregated domain\nsmallestPercentage => The percentage of Activities the smallest aggregated domain comprises\ncountRange => A convenience string displaying \"smallestCount - largestCount\" or just one number if both are the same\npercentRange => A convenience string displaying \"smallestPercentage - largestPercentage\" or just one percentage if both are the same\ndomains => An array of all the domain URLs that met the threshold\ndomainsDelim => A comma-delimited string of all the domain URLs that met the threshold\ntitles => The friendly-name of the domain if one is present, otherwise the URL (IE youtube.com/c/34ldfa343 => \"My Youtube Channel Title\")\ntitlesDelim => A comma-delimited string of all the domain friendly-names\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -509,6 +509,7 @@
"type": "boolean"
},
"op": {
"description": "Is this Comment Author also the Author of the Submission this comment is in?",
"type": "boolean"
},
"removed": {
@@ -519,6 +520,13 @@
},
"stickied": {
"type": "boolean"
},
"submissionState": {
"description": "A list of SubmissionState attributes to test the Submission this comment is in",
"items": {
"$ref": "#/definitions/SubmissionState"
},
"type": "array"
}
},
"type": "object"
@@ -627,7 +635,7 @@
"type": "object"
},
"HistoryJSONConfig": {
"description": "Aggregates an Author's submission and comment history. Rule can be triggered on count/percent of total (for either or both comment/sub totals) as well as comment OP total.\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nactivityTotal => Total number of activities\nsubmissionTotal => Total number of submissions\ncommentTotal => Total number of comments\nopTotal => Total number of comments as OP\nthresholdSummary => A text summary of the first Criteria triggered with totals/percentages\ncriteria => The ThresholdCriteria object\nwindow => A text summary of the range of Activities considered (# of Items if number, time range if Duration)\n```",
"description": "Aggregates an Author's submission and comment history. Rule can be triggered on count/percent of total (for either or both comment/sub totals) as well as comment OP total.\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nactivityTotal => Total number of activities\nsubmissionTotal => Total number of submissions\ncommentTotal => Total number of comments\nopTotal => Total number of comments as OP\nthresholdSummary => A text summary of the first Criteria triggered with totals/percentages\ncriteria => The ThresholdCriteria object\nwindow => A text summary of the range of Activities considered (# of Items if number, time range if Duration)\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -728,7 +736,7 @@
"type": "object"
},
"RecentActivityRuleJSONConfig": {
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -936,7 +944,7 @@
"type": "object"
},
"RegexRuleJSONConfig": {
"description": "Test a (list of) Regular Expression against the contents or title of an Activity\n\nOptionally, specify a `window` of the User's history to additionally test against\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):",
"description": "Test a (list of) Regular Expression against the contents or title of an Activity\n\nOptionally, specify a `window` of the User's history to additionally test against\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -1023,7 +1031,7 @@
"type": "object"
},
"RepeatActivityJSONConfig": {
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
@@ -1217,6 +1225,12 @@
"is_self": {
"type": "boolean"
},
"link_flair_css_class": {
"type": "string"
},
"link_flair_text": {
"type": "string"
},
"locked": {
"type": "boolean"
},
@@ -1238,6 +1252,10 @@
},
"stickied": {
"type": "boolean"
},
"title": {
"description": "A valid regular expression to match against the title of the submission",
"type": "string"
}
},
"type": "object"

72
src/Server/public/app.css Normal file
View File

@@ -0,0 +1,72 @@
a {
text-decoration: underline;
}
.loading {
height: 35px;
fill: black;
display: none;
}
.connected .loading {
display: inline;
}
.dark .loading {
fill: white;
}
.sub {
display: none;
}
.sub.active {
display: inherit;
}
/*https://stackoverflow.com/a/48386400/1469797*/
.stats {
display: grid;
grid-template-columns: max-content auto;
grid-gap: 5px;
}
.stats.three {
grid-template-columns: max-content max-content auto;
}
.stats label {
text-align: right;
}
.stats label:after {
content: ":";
}
.has-tooltip {
/*position: relative;*/
}
.tooltip {
transition-delay: 0.5s;
transition-property: visibility;
visibility: hidden;
position: absolute;
/*right: 0;*/
margin-top:-35px;
}
.has-tooltip:hover .tooltip {
visibility: visible;
transition-delay: 0.2s;
transition-property: visibility;
z-index: 100;
}
.pointer {
cursor: pointer;
}
.botStats.hidden {
display: none;
}

View File

@@ -0,0 +1,21 @@
/* adapted from https://cdn.jsdelivr.net/npm/pretty-print-json@1.0/dist/pretty-print-json.css */
.json-key { color: brown; }
.json-string { color: olive; }
.json-number { color: navy; }
.json-boolean { color: teal; }
.json-null { color: dimgray; }
.json-mark { color: black; }
a.json-link { color: purple; transition: all 400ms; }
a.json-link:visited { color: slategray; }
a.json-link:hover { color: blueviolet; }
a.json-link:active { color: slategray; }
.dark .json-key { color: indianred; }
.dark .json-string { color: darkkhaki; }
.dark .json-number { color: deepskyblue; }
.dark .json-boolean { color: mediumseagreen; }
.dark .json-null { color: darkorange; }
.dark .json-mark { color: silver; }
.dark a.json-link { color: mediumorchid; }
.dark a.json-link:visited { color: slategray; }
.dark a.json-link:hover { color: violet; }
.dark a.json-link:active { color: silver; }

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<html>
<%- include('partials/head', {title: 'RCB OAuth Helper'}) %>
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
<body class="">
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
@@ -16,7 +16,7 @@
</ul>
<div>Copy these somewhere and then restart the application providing these as either arguments
or environmental variables as described in the <a
href="https://github.com/FoxxMD/reddit-context-bot#usage">usage section.</a>
href="https://github.com/FoxxMD/context-mod#usage">usage section.</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,65 @@
<html>
<head>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css"
integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ=="
crossorigin="anonymous"/>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css"
integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg=="
crossorigin="anonymous"/>
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
<link rel="stylesheet" href="public/themeToggle.css">
<link rel="stylesheet" href="public/app.css">
<link rel="stylesheet" href="public/json.css">
<title>CM for <%= botName %></title>
<!--<title><%# `CM for /u/${botName}`%></title>-->
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<!--icons from https://heroicons.com -->
</head>
<body style="user-select: none;" class="">
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
<%- include('partials/authTitle') %>
<div class="container mx-auto">
<div class="grid">
<div class="bg-white dark:bg-gray-700 dark:text-white">
<div class="p-6 md:px-10 md:py-6 space-y-3">
<div>Note: Comments have been removed</div>
<pre style="user-select: text;"><%- config %></pre>
</div>
</div>
</div>
</div>
</div>
<script>
document.querySelectorAll('.theme').forEach(el => {
el.addEventListener('click', e => {
e.preventDefault();
if (e.target.id === 'dark') {
document.body.classList.add('dark');
localStorage.setItem('ms-dark', 'yes');
} else {
document.body.classList.remove('dark');
localStorage.setItem('ms-dark', 'no');
}
document.querySelectorAll('.theme').forEach(el => {
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
});
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
})
})
document.querySelector("#themeToggle").checked = localStorage.getItem('ms-dark') !== 'no';
document.querySelector("#themeToggle").onchange = (e) => {
if (e.target.checked === true) {
document.body.classList.add('dark');
localStorage.setItem('ms-dark', 'yes');
} else {
document.body.classList.remove('dark');
localStorage.setItem('ms-dark', 'no');
}
}
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
<html>
<%- include('partials/head', {title: 'RCB'}) %>
<%- include('partials/head', {title: 'CM'}) %>
<body class="">
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">

View File

@@ -1,5 +1,5 @@
<html>
<%- include('partials/head', {title: 'RCB OAuth Helper'}) %>
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
<body class="">
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">

View File

@@ -1,5 +1,5 @@
<html>
<%- include('partials/head', {title: 'RCB'}) %>
<%- include('partials/head', {title: 'CM'}) %>
<body class="">
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
@@ -10,7 +10,7 @@
<div class="p-6 md:px-10 md:py-6">
<div class="text-xl mb-4">Sorry!</div>
<div class="space-y-3">
<div>Your account was successfully logged in but you do not have access to this RCB instance because either:</div>
<div>Your account was successfully logged in but you do not have access to this ContextMod instance because either:</div>
<ul class="list-inside list-disc">
<li>The Bot account used by this instance is not a Moderator of any Subreddits you are also a Moderator of or</li>
<li>the Bot is a Moderator of one of your Subreddits but the Operator of this instance is not currently running the instance on your Subreddits.</li>

View File

@@ -4,7 +4,7 @@
<div class="flex items-center flex-grow pr-4">
<div class="px-4 width-full relative">
<span>
<a href="https://github.com/FoxxMD/reddit-context-bot">RCB</a> for <a href="https://reddit.com/user/<%= botName %>">/u/<%= botName %></a>
<a href="https://github.com/FoxxMD/context-mod">ContextMod</a> for <a href="<%= botLink %>"><%= botName %></a>
</span>
<span class="inline-block -mb-3 ml-2">
<label style="font-size:2.5px;">

View File

@@ -7,78 +7,9 @@
crossorigin="anonymous"/>
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
<link rel="stylesheet" href="public/themeToggle.css">
<style>
a {
text-decoration: underline;
}
.loading {
height: 35px;
fill: black;
display: none;
}
.connected .loading {
display: inline;
}
.dark .loading {
fill: white;
}
.sub {
display: none;
}
.sub.active {
display: inherit;
}
/*https://stackoverflow.com/a/48386400/1469797*/
.stats {
display: grid;
grid-template-columns: max-content auto;
grid-gap: 5px;
}
.stats label {
text-align: right;
}
.stats label:after {
content: ":";
}
.has-tooltip {
/*position: relative;*/
}
.tooltip {
transition-delay: 0.5s;
transition-property: visibility;
visibility: hidden;
position: absolute;
/*right: 0;*/
margin-top:-35px;
}
.has-tooltip:hover .tooltip {
visibility: visible;
transition-delay: 0.2s;
transition-property: visibility;
z-index: 100;
}
.pointer {
cursor: pointer;
}
.botStats.hidden {
display: none;
}
</style>
<title><%= title !== undefined ? title : `RCB for /u/${botName}`%></title>
<!--<title><%# `RCB for /u/${botName}`%></title>-->
<link rel="stylesheet" href="public/app.css">
<title><%= title !== undefined ? title : `CM for ${botName}`%></title>
<!--<title><%# `CM for /u/${botName}`%></title>-->
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">

View File

@@ -3,7 +3,7 @@
<div class="flex items-center justify-between">
<div class="flex items-center flex-grow pr-4">
<div class="px-4 width-full relative">
<div><a href="https://github.com/FoxxMD/reddit-context-bot">RCB</a> <%= title %></div>
<div><a href="https://github.com/FoxxMD/context-mod">ContextMod</a> <%= title %></div>
</div>
</div>
<div class="flex items-center flex-end text-sm">

View File

@@ -107,10 +107,11 @@
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 space-y-3 p-2 text-left'>
<div>The <b>Queue</b> controls processing of <b>Activities</b> ingested from <b>Events.</b></div>
<ul class="list-inside list-disc">
<li><b>Starting</b> the Queue will being Processing (running checks on) queued Activities based on the max number of workers available</li>
<li><b>Stopping</b> the Queue will prevent queued Activities from being Processed, after any current Activities are finished Processing.</li>
<li><b>Starting</b> the Queue will begin Processing (running checks on) Queued Activities based on the max number of workers available</li>
<li><b>Pausing</b> the Queue will prevent any Queued Activities from being Processed (excluding already running Activities)</li>
<li><b>Stopping</b> the Queue will <b>clear any existing Queued Activities</b> and prevent any subsequently queued Activities from being Processed</li>
</ul>
<div>If all available workers are processing Activities then new Activities returned from <b>Events</b> will be put marked as <b>Queued</b></div>
<div>If all available workers are processing Activities then new Activities returned from <b>Events</b> will be <b>Queued</b></div>
</span>
<span>
Queue
@@ -145,7 +146,8 @@
<div><b>Events</b> controls polling (monitoring) of <b>Activity Sources</b> (unmoderated, modqueue, comments, etc.)</div>
<ul class="list-inside list-disc">
<li><b>Starting</b> Events will cause polling to begin. Any new Activities discovered after polling begins will be sent to <b>Queue</b></li>
<li><b>Stopping</b> Events will cause polling to stop.</li>
<li><b>Pausing</b> Events will cause polling to stop.</li>
<li><b>Stopping</b> Events will cause polling to stop and rebuild polling behavior on next start.</li>
</ul>
</span>
<span>
@@ -175,15 +177,6 @@
</div>
</div>
</div>
<label>Activities</label>
<span class="has-tooltip">
<span style="margin-top:-55px"
class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black'>
<div>Max Concurrent Processing</div>
<div>Config: <%= data.maxWorkers %></div>
</span>
<span><%= `${data.runningActivities} Processing / ${data.queuedActivities} Queued` %></span>
</span>
<label>Slow Mode</label>
<span><%= data.delayBy %></span>
<% } %>
@@ -212,6 +205,39 @@
<span id="nextHeartbeatHuman"><%= data.nextHeartbeatHuman %></span>
</span>
<% } %>
<label>
<span class="has-tooltip">
<span style="margin-top:35px"
class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 space-y-3 p-2 text-left'>
<div>The total number of <b>Activities</b> (Comment/Submission) currently being processed by the bot or queued to be processed.</div>
<div>
Max Concurrent Processing
<ul class="list-inside list-disc">
<li>Real Max: <%= data.maxWorkers %></li>
<% if (data.name !== 'All') { %>
<li>Config Max: <%= data.subMaxWorkers %></li>
<% } %>
<li>Global Max: <%= data.globalMaxWorkers %></li>
</ul>
</div>
</span>
<span>Activities <svg xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 inline-block cursor-help"
fill="none"
viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</span>
</span>
</label>
<span><%= `${data.runningActivities} Processing / ${data.queuedActivities} Queued` %></span>
<% if (data.name === 'All' && isOperator) { %>
<label>Operators</label>
<span><%= operators %></span>
<% } %>
</div>
<% if (data.name !== 'All') { %>
<ul class="list-disc list-inside mt-4">
@@ -297,7 +323,8 @@
<label>Location</label>
<span>
<a style="display: inline"
href="<%= data.wikiHref %>"><%= data.wikiLocation %></a>
href="<%= data.wikiHref %>"><%= data.wikiLocation %></a> | <a style="display: inline" target="_blank"
href="/config?subreddit=<%= data.name %>">View</a>
</span>
</div>
</div>
@@ -396,19 +423,19 @@
</ul>
</div>
<% } else { %>
<div>This subreddit is using the default, <b>application-wide shared cache</b> because its <b
class="font-mono">caching</b> configuration is not specified.</div>
<div>This subreddit is using <b>a private cache instance</b> because its <b
class="font-mono">caching</b> configuration is non-default.</div>
<div>Pros:
<ul class="list-inside list-disc">
<li>All subreddits can utilize any cached authors/etc., reduces overall api usage</li>
<li>Bot Operator can fine tune cache without subreddit interaction</li>
<li>Custom configuration (TTLs, max size) may fit subreddit usage of the bot better</li>
<li>Using a redis backend may increase performance for very large caches</li>
</ul>
</div>
<div>
Cons:
<ul class="list-inside list-disc">
<li>Subreddits must use default TTLs which may not fit use case for rules</li>
<li>Bots operating subreddits with dramatically contrasting caching requirements may suffer in performance/api usage</li>
<li>Using a private <span class="font-mono">memory</span> cache produces a larger memory burden for the operator's host machine since it is not shared</li>
<li>Bot Operator cannot fine-tune caching parameters for this subreddit</li>
</ul>
</div>
<% } %>
@@ -432,34 +459,75 @@
<label>Keys</label>
<span><%= data.stats.cache.currentKeyCount %></span>
<label>Calls</label>
<span><span><%= data.stats.cache.totalRequests %></span> <span>(<%= data.stats.cache.requestRate %>/10min)</span></span>
<span><span><%= data.stats.cache.totalRequests %></span> | <%= data.stats.cache.totalMiss %> (<%= data.stats.cache.missPercent %>) miss</span>
<label>Call Rate</label>
<span><%= data.stats.cache.requestRate %>/10min</span>
</div>
<div class="text-left py-2">
<span class="font-semibold">Calls Breakdown</span>
<span class="has-tooltip">
<span style="margin-top:30px; margin-left: -25em"
class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 space-y-3 p-2'>
<div>Number of cache requests for each type of cacheable item.</div>
<div class="mb-2"><b>Miss</b> means the cache was empty and data had to be fully acquired/processed.</div>
<div class="stats three">
<label>Author</label>
<span><%= data.stats.cache.types.author.requests %> | <%= data.stats.cache.types.author.miss %> (<%= data.stats.cache.types.author.missPercent %>) miss</span>
<span>
- Results from <span class="font-mono">window</span> criteria.
</span>
<label>Author Criteria</label>
<span><%= data.stats.cache.types.authorCrit.requests %> | <%= data.stats.cache.types.authorCrit.miss %> (<%= data.stats.cache.types.authorCrit.missPercent %>) miss</span>
<span>
- <span class="font-mono">authorIs</span> results
</span>
<label>Item Criteria</label>
<span><%= data.stats.cache.types.itemCrit.requests %> | <%= data.stats.cache.types.itemCrit.miss %> (<%= data.stats.cache.types.itemCrit.missPercent %>) miss</span>
<span>
- <span class="font-mono">itemIs</span> results
</span>
<label>Comment Check</label>
<span><%= data.stats.cache.types.commentCheck.requests %> | <%= data.stats.cache.types.commentCheck.miss %> (<%= data.stats.cache.types.commentCheck.missPercent %>) miss</span>
<span>
- <span class="font-mono">cacheUserResult</span> results
</span>
<label>Submissions</label>
<span><%= data.stats.cache.types.submission.requests %> | <%= data.stats.cache.types.submission.miss %> (<%= data.stats.cache.types.submission.missPercent %>) miss</span>
<span>
</span>
<label>Comments</label>
<span><%= data.stats.cache.types.comment.requests %> | <%= data.stats.cache.types.comment.miss %> (<%= data.stats.cache.types.comment.missPercent %>) miss</span>
<span>
</span>
<label>Content</label>
<span><%= data.stats.cache.types.content.requests %> | <%= data.stats.cache.types.content.miss %> (<%= data.stats.cache.types.content.missPercent %>) miss</span>
<span>
- footer/comment/ban/message content.
</span>
<label>UserNote</label>
<span><%= data.stats.cache.types.userNotes.requests %> | <%= data.stats.cache.types.userNotes.miss %> (<%= data.stats.cache.types.userNotes.missPercent %>) miss</span>
<span>
</span>
</div>
</span>
<span class="font-semibold underline" style="text-decoration-style: dotted">Calls Breakdown</span>
</span>
<span class="has-tooltip">
<span style="right: 0;"
<span style="margin-top:30px; margin-left:-15em;"
class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2 space-y-3 p-2'>
<div>Number of calls to cache made for each type of cached item.</div>
<div>The <b>miss %</b> is the percentage of cache calls that were empty and data had to be fully acquired/processed.</div>
<div>
<ul class="list-inside list-disc">
<li><b>Author</b> - Cached history for an Activity's Author acquired from <span
class="font-mono">window</span> criteria. A missed cached call means at least one API call must be made.</li>
<li><b>Criteria</b> - Cached results of an <span
class="font-mono">authorIs</span> test. A missed cached call may require at least one API call.</li>
<li><b>Content</b> - Cached content for footer/comment/ban content. A missed cached call requires one API call.</li>
<li><b>UserNote</b> - Cached UserNotes. A missed cached call requires one API call.</li>
</ul>
</div>
<div>
Some tips/tricks for cache:
</div>
<ul class="list-inside list-disc">
<li>Only need to pay attention to caching if a subreddit uses the API/cache heavily IE high-volume comment checks or very large check sets for submissions</li>
<li>Increasing TTL will reduce cache misses and api usage at the expensive of a larger cache and stale results</li>
<ul class="list-inside list-disc space-y-2">
<li>Only need to pay attention to caching if a subreddit uses the API/cache heavily IE high-volume comment checks or very large check sets for submissions -- check <b>Calls</b> rate</li>
<li>Cache misses are roughly 1 to 1 with making an API call -- IE reducing cache miss by 1 reduces api calls by 1.</li>
<li>Re-using <span
class="font-mono">window</span> and <span
class="font-mono">authorIs</span> values in configuration will enable the bot to re-use these results and thus reduce cache misses/api usage</li>
class="font-mono">window,authorIs,</span> and <span
class="font-mono">itemIs</span> values in configuration will enable the bot to re-use these results and reduce cache misses</li>
<li>A high miss % can indicate that above values are not being re-used, TTLs are not long enough, or most items are are new which means the cache can't be used efficiently.</li>
<li>Increasing TTL will reduce cache misses at the expense of a larger cache and stale results</li>
</ul>
</span>
<span>
@@ -475,16 +543,6 @@
</span>
</span>
</div>
<div class="stats">
<label>Author</label>
<span><%= data.stats.cache.types.author.requests %> (<%= data.stats.cache.types.author.missPercent %> miss)</span>
<label>Criteria</label>
<span><%= data.stats.cache.types.authorCrit.requests %> (<%= data.stats.cache.types.authorCrit.missPercent %> miss)</span>
<label>Content</label>
<span><%= data.stats.cache.types.content.requests %> (<%= data.stats.cache.types.content.missPercent %> miss)</span>
<label>UserNote</label>
<span><%= data.stats.cache.types.userNotes.requests %> (<%= data.stats.cache.types.userNotes.missPercent %> miss)</span>
</div>
</div>
</div>
</div>

View File

@@ -55,17 +55,21 @@ export interface CheckTask {
export interface RuntimeManagerOptions extends ManagerOptions {
sharedModqueue?: boolean;
wikiLocation?: string;
botName: string;
maxWorkers: number;
}
export class Manager {
subreddit: Subreddit;
client: Snoowrap;
logger: Logger;
botName: string;
pollOptions: PollingOptionsStrong[] = [];
submissionChecks!: SubmissionCheck[];
commentChecks!: CommentCheck[];
resources!: SubredditResources;
wikiLocation: string = 'botconfig/contextbot';
wikiLocation: string;
lastWikiRevision?: DayjsObj
lastWikiCheck: DayjsObj = dayjs();
//wikiUpdateRunning: boolean = false;
@@ -78,6 +82,8 @@ export class Manager {
globalDryRun?: boolean;
emitter: EventEmitter = new EventEmitter();
queue: QueueObject<CheckTask>;
globalMaxWorkers: number;
subMaxWorkers?: number;
displayLabel: string;
currentLabels: string[] = [];
@@ -152,6 +158,8 @@ export class Manager {
currentKeyCount: 0,
isShared: false,
totalRequests: 0,
totalMiss: 0,
missPercent: '0%',
requestRate: 0,
types: cacheStats()
},
@@ -176,8 +184,8 @@ export class Manager {
return this.displayLabel;
}
constructor(sub: Subreddit, client: Snoowrap, logger: Logger, opts: RuntimeManagerOptions = {}) {
const {dryRun, sharedModqueue = false} = opts;
constructor(sub: Subreddit, client: Snoowrap, logger: Logger, opts: RuntimeManagerOptions = {botName: 'ContextMod', maxWorkers: 1}) {
const {dryRun, sharedModqueue = false, wikiLocation = 'botconfig/contextbot', botName, maxWorkers} = opts;
this.displayLabel = opts.nickname || `${sub.display_name_prefixed}`;
const getLabels = this.getCurrentLabels;
const getDisplay = this.getDisplay;
@@ -192,27 +200,15 @@ export class Manager {
}
}, mergeArr);
this.globalDryRun = dryRun;
this.wikiLocation = wikiLocation;
this.sharedModqueue = sharedModqueue;
this.subreddit = sub;
this.client = client;
this.notificationManager = new NotificationManager(this.logger, this.subreddit, this.displayLabel);
this.botName = botName;
this.globalMaxWorkers = maxWorkers;
this.notificationManager = new NotificationManager(this.logger, this.subreddit, this.displayLabel, botName);
this.queue = queue(async (task: CheckTask, cb) => {
if(this.delayBy !== undefined) {
this.logger.debug(`SOFT API LIMIT MODE: Delaying Event run by ${this.delayBy} seconds`);
await sleep(this.delayBy * 1000);
}
await this.runChecks(task.checkType, task.activity, task.options);
}
// TODO allow concurrency??
, 1);
this.queue.error((err, task) => {
this.logger.error('Encountered unhandled error while processing Activity, processing stopped early');
this.logger.error(err);
});
this.queue.drain(() => {
this.logger.debug('All queued activities have been processed.');
});
this.queue = this.generateQueue(this.getMaxWorkers(this.globalMaxWorkers));
this.queue.pause();
this.eventsSampleInterval = setInterval((function(self) {
@@ -256,6 +252,49 @@ export class Manager {
})(this), 10000);
}
protected getMaxWorkers(subMaxWorkers?: number) {
let maxWorkers = this.globalMaxWorkers;
if (subMaxWorkers !== undefined) {
if (subMaxWorkers > maxWorkers) {
this.logger.warn(`Config specified ${subMaxWorkers} max queue workers but global max is set to ${this.globalMaxWorkers} -- will use global max`);
} else {
maxWorkers = subMaxWorkers;
}
}
if (maxWorkers < 1) {
this.logger.warn(`Max queue workers must be greater than or equal to 1, specified: ${maxWorkers}. Will use 1.`);
maxWorkers = 1;
}
return maxWorkers;
}
protected generateQueue(maxWorkers: number) {
if (maxWorkers > 1) {
this.logger.warn(`Setting max queue workers above 1 (specified: ${maxWorkers}) may have detrimental effects to log readability and api usage. Consult the documentation before using this advanced/experimental feature.`);
}
const q = queue(async (task: CheckTask, cb) => {
if (this.delayBy !== undefined) {
this.logger.debug(`SOFT API LIMIT MODE: Delaying Event run by ${this.delayBy} seconds`);
await sleep(this.delayBy * 1000);
}
await this.runChecks(task.checkType, task.activity, task.options);
}
, maxWorkers);
q.error((err, task) => {
this.logger.error('Encountered unhandled error while processing Activity, processing stopped early');
this.logger.error(err);
});
q.drain(() => {
this.logger.debug('All queued activities have been processed.');
});
this.logger.info(`Generated new Queue with ${maxWorkers} max workers`);
return q;
}
protected parseConfigurationFromObject(configObj: object) {
try {
const configBuilder = new ConfigBuilder({logger: this.logger});
@@ -268,6 +307,9 @@ export class Manager {
footer,
nickname,
notifications,
queue: {
maxWorkers = undefined,
} = {},
} = configManagerOpts || {};
this.pollOptions = buildPollingOptions(polling);
this.dryRun = this.globalDryRun || dryRun;
@@ -278,12 +320,19 @@ export class Manager {
this.resources.footer = footer;
}
this.subMaxWorkers = maxWorkers;
const realMax = this.getMaxWorkers(this.subMaxWorkers);
if(realMax !== this.queue.concurrency) {
this.queue = this.generateQueue(realMax);
this.queue.pause();
}
this.logger.info(`Dry Run: ${this.dryRun === true}`);
for (const p of this.pollOptions) {
this.logger.info(`Polling Info => ${pollingInfo(p)}`)
}
this.notificationManager = new NotificationManager(this.logger, this.subreddit, this.displayLabel, notifications);
this.notificationManager = new NotificationManager(this.logger, this.subreddit, this.displayLabel, this.botName, notifications);
const {events, notifiers} = this.notificationManager.getStats();
const notifierContent = notifiers.length === 0 ? 'None' : notifiers.join(', ');
const eventContent = events.length === 0 ? 'None' : events.join(', ');
@@ -296,6 +345,7 @@ export class Manager {
caching
};
this.resources = ResourceManager.set(this.subreddit.display_name, resourceConfig);
this.resources.setLogger(this.logger);
this.logger.info('Subreddit-specific options updated');
this.logger.info('Building Checks...');
@@ -457,7 +507,11 @@ export class Manager {
let triggered = false;
for (const check of checks) {
if (checkNames.length > 0 && !checkNames.map(x => x.toLowerCase()).some(x => x === check.name.toLowerCase())) {
this.logger.warn(`Check ${check.name} not in array of requested checks to run, skipping`);
this.logger.warn(`Check ${check.name} not in array of requested checks to run, skipping...`);
continue;
}
if(!check.enabled) {
this.logger.info(`Check ${check.name} not run because it is not enabled, skipping...`);
continue;
}
checksRun++;
@@ -465,6 +519,7 @@ export class Manager {
let currentResults: RuleResult[] = [];
try {
const [checkTriggered, checkResults] = await check.runRules(item, allRuleResults);
await check.setCacheResult(item, checkTriggered);
currentResults = checkResults;
totalRulesRun += checkResults.length;
allRuleResults = allRuleResults.concat(determineNewResults(allRuleResults, checkResults));
@@ -483,7 +538,7 @@ export class Manager {
if(check.notifyOnTrigger) {
const ar = runActions.map(x => x.getActionUniqueName()).join(', ');
this.notificationManager.handle('eventActioned', 'Check Triggered', `Check "${check.name}" was triggered on Event: \n ${ePeek} \n\n with the following actions run: ${ar}`);
this.notificationManager.handle('eventActioned', 'Check Triggered', `Check "${check.name}" was triggered on Event: \n\n ${ePeek} \n\n with the following actions run: ${ar}`);
}
break;
}

View File

@@ -1,9 +1,11 @@
import Snoowrap, {RedditUser, Comment, Submission} from "snoowrap";
import Snoowrap, {RedditUser} from "snoowrap";
import objectHash from 'object-hash';
import {
activityIsDeleted, activityIsFiltered,
activityIsRemoved,
AuthorActivitiesOptions,
AuthorTypedActivitiesOptions, BOT_LINK,
getAuthorActivities,
getAuthorActivities, singleton,
testAuthorCriteria
} from "../Utils/SnoowrapUtils";
import Subreddit from 'snoowrap/dist/objects/Subreddit';
@@ -19,9 +21,9 @@ import {
} from "../util";
import LoggedError from "../Utils/LoggedError";
import {
CacheOptions,
Footer, OperatorConfig, ResourceStats,
SubredditCacheConfig, TTLConfig
CacheOptions, CommentState,
Footer, OperatorConfig, ResourceStats, SubmissionState,
SubredditCacheConfig, TTLConfig, TypedActivityStates
} from "../Common/interfaces";
import UserNotes from "./UserNotes";
import Mustache from "mustache";
@@ -29,6 +31,8 @@ import he from "he";
import {AuthorCriteria} from "../Author/Author";
import {SPoll} from "./Streams";
import {Cache} from 'cache-manager';
import {Submission, Comment} from "snoowrap/dist/objects";
import {cacheTTLDefaults} from "../Common/defaults";
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.';
@@ -40,7 +44,7 @@ export interface SubredditResourceConfig extends Footer {
interface SubredditResourceOptions extends Footer {
ttl: Required<TTLConfig>
cache?: Cache
cache: Cache
cacheType: string;
cacheSettingsHash: string
subreddit: Subreddit,
@@ -52,17 +56,21 @@ export interface SubredditResourceSetOptions extends SubredditCacheConfig, Foote
export class SubredditResources {
//enabled!: boolean;
protected authorTTL!: number;
protected useSubredditAuthorCache!: boolean;
protected wikiTTL!: number;
protected authorTTL: number = cacheTTLDefaults.authorTTL;
protected wikiTTL: number = cacheTTLDefaults.wikiTTL;
protected submissionTTL: number = cacheTTLDefaults.submissionTTL;
protected commentTTL: number = cacheTTLDefaults.commentTTL;
protected filterCriteriaTTL: number = cacheTTLDefaults.filterCriteriaTTL;
name: string;
protected logger: Logger;
userNotes: UserNotes;
footer: false | string = DEFAULT_FOOTER;
subreddit: Subreddit
cache?: Cache
cache: Cache
cacheType: string
cacheSettingsHash?: string;
pruneInterval?: any;
stats: { cache: ResourceStats };
@@ -74,6 +82,7 @@ export class SubredditResources {
userNotesTTL,
authorTTL,
wikiTTL,
filterCriteriaTTL,
},
cache,
cacheType,
@@ -85,6 +94,7 @@ export class SubredditResources {
this.cacheType = cacheType;
this.authorTTL = authorTTL;
this.wikiTTL = wikiTTL;
this.filterCriteriaTTL = filterCriteriaTTL;
this.subreddit = subreddit;
this.name = name;
if (logger === undefined) {
@@ -103,20 +113,39 @@ export class SubredditResources {
this.stats.cache.userNotes.miss += miss ? 1 : 0;
}
this.userNotes = new UserNotes(userNotesTTL, this.subreddit, this.logger, this.cache, cacheUseCB)
if(this.cacheType === 'memory' && this.cacheSettingsHash !== 'default') {
const min = Math.min(...([wikiTTL, authorTTL, userNotesTTL].filter(x => x !== 0)));
if(min > 0) {
// set default prune interval
this.pruneInterval = setInterval(() => {
// @ts-ignore
this.defaultCache?.store.prune();
this.logger.debug('Pruned cache');
// prune interval should be twice the smallest TTL
},min * 1000 * 2)
}
}
}
async getCacheKeyCount() {
if (this.cache !== undefined && this.cache.store.keys !== undefined) {
if (this.cache.store.keys !== undefined) {
return (await this.cache.store.keys()).length;
}
return 0;
}
getStats() {
const totals = Object.values(this.stats.cache).reduce((acc, curr) => ({
miss: acc.miss + curr.miss,
req: acc.req + curr.requests,
}), {miss: 0, req: 0});
return {
cache: {
// TODO could probably combine these two
totalRequests: Object.values(this.stats.cache).reduce((acc, curr) => acc + curr.requests, 0),
totalRequests: totals.req,
totalMiss: totals.miss,
missPercent: `${formatNumber(totals.miss === 0 || totals.req === 0 ? 0 :(totals.miss/totals.req) * 100, {toFixed: 0})}%`,
types: Object.keys(this.stats.cache).reduce((acc, curr) => {
const per = acc[curr].miss === 0 ? 0 : formatNumber(acc[curr].miss / acc[curr].requests) * 100;
// @ts-ignore
@@ -127,8 +156,48 @@ export class SubredditResources {
}
}
setLogger(logger: Logger) {
this.logger = logger.child({labels: ['Resource Cache']}, mergeArr);
}
async getActivity(item: Submission | Comment) {
try {
if (item instanceof Submission && this.submissionTTL > 0) {
this.stats.cache.submission.requests++;
const cachedSubmission = await this.cache.get(`sub-${item.name}`);
if (cachedSubmission !== undefined) {
this.logger.debug(`Cache Hit: Submission ${item.name}`);
return cachedSubmission;
}
// @ts-ignore
const submission = await item.fetch();
this.stats.cache.submission.miss++;
await this.cache.set(`sub-${item.name}`, submission, {ttl: this.submissionTTL});
return submission;
} else if (this.commentTTL > 0) {
this.stats.cache.comment.requests++;
const cachedComment = await this.cache.get(`comm-${item.name}`);
if (cachedComment !== undefined) {
this.logger.debug(`Cache Hit: Comment ${item.name}`);
return cachedComment;
}
// @ts-ignore
const comment = await item.fetch();
this.stats.cache.comment.miss++;
await this.cache.set(`comm-${item.name}`, comment, {ttl: this.commentTTL});
return comment;
} else {
// @ts-ignore
return await item.fetch();
}
} catch (err) {
this.logger.error('Error while trying to fetch a cached activity', err);
throw err.logged;
}
}
async getAuthorActivities(user: RedditUser, options: AuthorTypedActivitiesOptions): Promise<Array<Submission | Comment>> {
if (this.cache !== undefined && this.authorTTL > 0) {
if (this.authorTTL > 0) {
const userName = user.name;
const hashObj: any = {...options, userName};
if (this.useSubredditAuthorCache) {
@@ -181,7 +250,7 @@ export class SubredditResources {
// try to get cached value first
let hash = `${subreddit.display_name}-${cacheKey}`;
if (this.cache !== undefined && this.wikiTTL > 0) {
if (this.wikiTTL > 0) {
this.stats.cache.content.requests++;
const cachedContent = await this.cache.get(hash);
if (cachedContent !== undefined) {
@@ -201,14 +270,22 @@ export class SubredditResources {
sub = subreddit;
} else {
// @ts-ignore
const client = subreddit._r as Snoowrap;
const client = singleton.getClient();
sub = client.getSubreddit(wikiContext.subreddit);
}
try {
// @ts-ignore
const wikiPage = sub.getWikiPage(wikiContext.wiki);
wikiContent = await wikiPage.content_md;
} catch (err) {
const msg = `Could not read wiki page. Please ensure the page 'https://reddit.com${sub.display_name_prefixed}wiki/${wikiContext}' exists and is readable`;
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);
}
@@ -223,15 +300,15 @@ export class SubredditResources {
}
}
if (this.cache !== undefined && this.wikiTTL > 0) {
this.cache.set(hash, wikiContent, this.wikiTTL);
if (this.wikiTTL > 0) {
this.cache.set(hash, wikiContent, {ttl: this.wikiTTL});
}
return wikiContent;
}
async testAuthorCriteria(item: (Comment | Submission), authorOpts: AuthorCriteria, include = true) {
if (this.cache !== undefined && this.authorTTL > 0) {
if (this.filterCriteriaTTL > 0) {
const hashObj = {itemId: item.id, ...authorOpts, include};
const hash = `authorCrit-${objectHash.sha1(hashObj)}`;
this.stats.cache.authorCrit.requests++;
@@ -251,6 +328,174 @@ export class SubredditResources {
return await testAuthorCriteria(item, authorOpts, include, this.userNotes);
}
async testItemCriteria(i: (Comment | Submission), s: TypedActivityStates) {
if (this.filterCriteriaTTL > 0) {
let item = i;
let states = s;
// optimize for submission only checks on comment item
if (item instanceof Comment && states.length === 1 && Object.keys(states[0]).length === 1 && (states[0] as CommentState).submissionState !== undefined) {
// get submission
const client = singleton.getClient();
// @ts-ignore
const subProxy = await client.getSubmission(await i.link_id);
// @ts-ignore
item = await this.getActivity(subProxy);
states = (states[0] as CommentState).submissionState as SubmissionState[];
}
try {
const hashObj = {itemId: item.name, ...states};
const hash = `itemCrit-${objectHash.sha1(hashObj)}`;
this.stats.cache.itemCrit.requests++;
const cachedItem = await this.cache.get(hash);
if (cachedItem !== undefined) {
this.logger.debug(`Cache Hit: Item Check on ${item.name}`);
return cachedItem as boolean;
}
const itemResult = await this.isItem(item, states, this.logger);
this.stats.cache.itemCrit.miss++;
const res = await this.cache.set(hash, itemResult, {ttl: this.filterCriteriaTTL});
return itemResult;
} catch (err) {
if (err.logged !== true) {
this.logger.error('Error occurred while testing item criteria', err);
}
throw err;
}
}
return await this.isItem(i, s, this.logger);
}
async isItem (item: Submission | Comment, stateCriteria: TypedActivityStates, logger: Logger) {
if (stateCriteria.length === 0) {
return true;
}
const log = logger.child({leaf: 'Item Check'});
for (const crit of stateCriteria) {
const pass = await (async () => {
for (const k of Object.keys(crit)) {
// @ts-ignore
if (crit[k] !== undefined) {
switch (k) {
case 'submissionState':
if(!(item instanceof Comment)) {
log.warn('`submissionState` is not allowed in `itemIs` criteria when the main Activity is a Submission');
continue;
}
// get submission
const client = singleton.getClient();
// @ts-ignore
const subProxy = await client.getSubmission(await item.link_id);
// @ts-ignore
const sub = await this.getActivity(subProxy);
// @ts-ignore
const res = await this.testItemCriteria(sub, crit[k] as SubmissionState[], logger);
if(!res) {
return false;
}
break;
case 'removed':
const removed = activityIsRemoved(item);
if (removed !== crit['removed']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${removed}`)
return false
}
break;
case 'deleted':
const deleted = activityIsDeleted(item);
if (deleted !== crit['deleted']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${deleted}`)
return false
}
break;
case 'filtered':
const filtered = activityIsFiltered(item);
if (filtered !== crit['filtered']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${filtered}`)
return false
}
break;
case 'title':
if((item instanceof Comment)) {
log.warn('`title` is not allowed in `itemIs` criteria when the main Activity is a Comment');
continue;
}
// @ts-ignore
const titleReg = crit[k] as string;
try {
if(null === item.title.match(titleReg)) {
// @ts-ignore
log.debug(`Failed to match title as regular expression: ${titleReg}`);
return false;
}
} catch (err) {
log.error(`An error occurred while attempting to match title against string as regular expression: ${titleReg}. Most likely the string does not make a valid regular expression.`, err);
return false
}
break;
default:
// @ts-ignore
if (item[k] !== undefined) {
// @ts-ignore
if (item[k] !== crit[k]) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item[k]}`)
return false
}
} else {
log.warn(`Tried to test for Item property '${k}' but it did not exist`);
}
break;
}
}
}
log.debug(`Passed: ${JSON.stringify(crit)}`);
return true;
})() as boolean;
if (pass) {
return true
}
}
return false
}
async getCommentCheckCacheResult(item: Comment, checkConfig: object): Promise<boolean | undefined> {
const criteria = {
author: item.author.name,
submission: item.link_id,
...checkConfig
}
const hash = objectHash.sha1(criteria);
this.stats.cache.commentCheck.requests++;
const result = await this.cache.get(hash) as boolean | undefined;
if(result === undefined) {
this.stats.cache.commentCheck.miss++;
}
this.logger.debug(`Cache Hit: Comment Check for ${item.author.name} in Submission ${item.link_id}`);
return result;
}
async setCommentCheckCacheResult(item: Comment, checkConfig: object, result: boolean, ttl: number) {
const criteria = {
author: item.author.name,
submission: item.link_id,
...checkConfig
}
const hash = objectHash.sha1(criteria);
// don't set if result is already cached
if(undefined !== await this.cache.get(hash)) {
this.logger.debug(`Check result already cached for User ${item.author.name} on Submission ${item.link_id}`);
} else {
await this.cache.set(hash, result, { ttl });
this.logger.debug(`Cached check result '${result}' for User ${item.author.name} on Submission ${item.link_id} for ${ttl} seconds`);
}
}
async generateFooter(item: Submission | Comment, actionFooter?: false | string) {
let footer = actionFooter !== undefined ? actionFooter : this.footer;
if (footer === false) {
@@ -270,9 +515,11 @@ class SubredditResourcesManager {
authorTTL: number = 10000;
enabled: boolean = true;
modStreams: Map<string, SPoll<Snoowrap.Submission | Snoowrap.Comment>> = new Map();
defaultCache?: Cache;
defaultCache!: Cache;
cacheType: string = 'none';
cacheHash!: string;
ttlDefaults!: Required<TTLConfig>;
pruneInterval: any;
setDefaultsFromConfig(config: OperatorConfig) {
const {
@@ -280,16 +527,35 @@ class SubredditResourcesManager {
authorTTL,
userNotesTTL,
wikiTTL,
commentTTL,
submissionTTL,
filterCriteriaTTL,
provider,
},
caching,
} = config;
this.cacheHash = objectHash.sha1(caching);
this.setTTLDefaults({authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL});
this.setDefaultCache(provider);
this.setTTLDefaults({authorTTL, userNotesTTL, wikiTTL});
}
setDefaultCache(options: CacheOptions) {
this.cacheType = options.store;
this.defaultCache = createCacheManager(options);
if(this.cacheType === 'memory') {
const min = Math.min(...([this.ttlDefaults.wikiTTL, this.ttlDefaults.authorTTL, this.ttlDefaults.userNotesTTL].filter(x => x !== 0)));
if(min > 0) {
// set default prune interval
this.pruneInterval = setInterval(() => {
// @ts-ignore
this.defaultCache?.store.prune();
// kinda hacky but whatever
const logger = winston.loggers.get('default');
logger.debug('Pruned Shared Cache');
// prune interval should be twice the smallest TTL
},min * 1000 * 2)
}
}
}
setTTLDefaults(def: Required<TTLConfig>) {
@@ -306,7 +572,15 @@ class SubredditResourcesManager {
set(subName: string, initOptions: SubredditResourceConfig): SubredditResources {
let hash = 'default';
const { caching, ...init } = initOptions;
let opts: SubredditResourceOptions;
let opts: SubredditResourceOptions = {
cache: this.defaultCache,
cacheType: this.cacheType,
cacheSettingsHash: hash,
ttl: this.ttlDefaults,
...init,
};
if(caching !== undefined) {
const {provider = 'memory', ...rest} = caching;
let cacheConfig = {
@@ -317,21 +591,16 @@ class SubredditResourcesManager {
},
}
hash = objectHash.sha1(cacheConfig);
const {provider: trueProvider, ...trueRest} = cacheConfig;
opts = {
cache: createCacheManager(trueProvider),
cacheType: trueProvider.store,
cacheSettingsHash: hash,
...init,
...trueRest,
};
} else {
opts = {
cache: this.defaultCache,
cacheType: this.cacheType,
cacheSettingsHash: hash,
ttl: this.ttlDefaults,
...init,
// only need to create private if there settings are actually different than the default
if(hash !== this.cacheHash) {
const {provider: trueProvider, ...trueRest} = cacheConfig;
opts = {
cache: createCacheManager(trueProvider),
cacheType: trueProvider.store,
cacheSettingsHash: hash,
...init,
...trueRest,
};
}
}

View File

@@ -54,12 +54,16 @@ export class UserNotes {
moderators?: RedditUser[];
logger: Logger;
identifier: string;
cache?: Cache
cache: Cache
cacheCB: Function;
users: Map<string, UserNote[]> = new Map();
constructor(ttl: number, subreddit: Subreddit, logger: Logger, cache: Cache | undefined, cacheCB: Function) {
saveDebounce: any;
debounceCB: any;
batchCount: number = 0;
constructor(ttl: number, subreddit: Subreddit, logger: Logger, cache: Cache, cacheCB: Function) {
this.notesTTL = ttl;
this.subreddit = subreddit;
this.logger = logger;
@@ -122,7 +126,7 @@ export class UserNotes {
payload.blob[item.author.name].ns.push(newNote.toRaw(payload.constants));
await this.saveData(payload);
if(this.notesTTL > 0 && this.cache !== undefined) {
if(this.notesTTL > 0) {
const currNotes = this.users.get(item.author.name) || [];
currNotes.push(newNote);
this.users.set(item.author.name, currNotes);
@@ -137,16 +141,26 @@ export class UserNotes {
}
async retrieveData(): Promise<RawUserNotesPayload> {
if (this.notesTTL > 0 && this.cache !== undefined) {
let cacheMiss;
if (this.notesTTL > 0) {
const cachedPayload = await this.cache.get(this.identifier);
if (cachedPayload !== undefined) {
this.cacheCB(false);
return cachedPayload as unknown as RawUserNotesPayload;
}
this.cacheCB(true);
cacheMiss = true;
}
try {
if(cacheMiss && this.debounceCB !== undefined) {
// timeout is still delayed. its our wiki data and we want it now! cm cacheworth 877 cache now
this.logger.debug(`Detected missed cache on usernotes retrieval while batch (${this.batchCount}) save is in progress, executing save immediately before retrieving new notes...`);
clearTimeout(this.saveDebounce);
await this.debounceCB();
this.debounceCB = undefined;
this.saveDebounce = undefined;
}
// @ts-ignore
this.wiki = await this.subreddit.getWikiPage('usernotes').fetch();
const wikiContent = this.wiki.content_md;
@@ -155,8 +169,8 @@ export class UserNotes {
userNotes.blob = inflateUserNotes(userNotes.blob);
if (this.notesTTL > 0 && this.cache !== undefined) {
await this.cache.set(`${this.subreddit.display_name}-usernotes`, userNotes, this.notesTTL);
if (this.notesTTL > 0) {
await this.cache.set(`${this.subreddit.display_name}-usernotes`, userNotes, {ttl: this.notesTTL});
this.users = new Map();
}
@@ -171,16 +185,37 @@ export class UserNotes {
async saveData(payload: RawUserNotesPayload): Promise<RawUserNotesPayload> {
const blob = deflateUserNotes(payload.blob);
const wikiPayload = {...payload, blob};
const wikiPayload = {text: JSON.stringify({...payload, blob}), reason: 'ContextBot edited usernotes'};
try {
// @ts-ignore
//this.wiki = await this.wiki.refresh();
// @ts-ignore
this.wiki = await this.subreddit.getWikiPage('usernotes').edit({text: JSON.stringify(wikiPayload), reason: 'ContextBot edited usernotes'});
if (this.notesTTL > 0 && this.cache !== undefined) {
await this.cache.set(this.identifier, payload, this.notesTTL);
if (this.notesTTL > 0) {
// debounce usernote save by 5 seconds -- effectively batch usernote saves
//
// so that if we are processing a ton of checks that write user notes we aren't calling to save the wiki page on every call
// since we also have everything in cache (most likely...)
//
// TODO might want to increase timeout to 10 seconds
if(this.saveDebounce !== undefined) {
clearTimeout(this.saveDebounce);
}
this.debounceCB = (async function () {
const p = wikiPayload;
// @ts-ignore
const self = this as UserNotes;
// @ts-ignore
self.wiki = await self.subreddit.getWikiPage('usernotes').edit(p);
self.logger.debug(`Batch saved ${self.batchCount} usernotes`);
self.debounceCB = undefined;
self.saveDebounce = undefined;
self.batchCount = 0;
}).bind(this);
this.saveDebounce = setTimeout(this.debounceCB,5000);
this.batchCount++;
this.logger.debug(`Saving Usernotes has been debounced for 5 seconds (${this.batchCount} batched)`)
await this.cache.set(this.identifier, payload, {ttl: this.notesTTL});
this.users = new Map();
} else {
// @ts-ignore
this.wiki = await this.subreddit.getWikiPage('usernotes').edit(wikiPayload);
}
return payload as RawUserNotesPayload;

View File

@@ -46,7 +46,7 @@ export const checks = new commander.Option('-h, --checks <checkNames...>', 'An o
export const proxy = new commander.Option('--proxy <proxyEndpoint>', 'Proxy Snoowrap requests through this endpoint (default: process.env.PROXY)');
export const operator = new commander.Option('--operator <name>', 'Username of the reddit user operating this application, used for displaying OP level info/actions in UI (default: process.env.OPERATOR)');
export const operator = new commander.Option('--operator <name...>', 'Username(s) of the reddit user(s) operating this application, used for displaying OP level info/actions in UI (default: process.env.OPERATOR)');
export const operatorDisplay = new commander.Option('--operatorDisplay <name>', 'An optional name to display who is operating this application in the UI (default: process.env.OPERATOR_DISPLAY || Anonymous)');

View File

@@ -24,9 +24,9 @@ import {Logger} from "winston";
import InvalidRegexError from "./InvalidRegexError";
import SimpleError from "./SimpleError";
import {AuthorCriteria} from "../Author/Author";
import { URL } from "url";
import {URL} from "url";
export const BOT_LINK = 'https://www.reddit.com/r/ContextModBot/comments/o1dugk/introduction_to_contextmodbot_and_rcb';
export const BOT_LINK = 'https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot';
export interface AuthorTypedActivitiesOptions extends AuthorActivitiesOptions {
type?: 'comment' | 'submission',
@@ -58,7 +58,7 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
let includes: string[] = [];
let excludes: string[] = [];
if(isActivityWindowCriteria(optWindow)) {
if (isActivityWindowCriteria(optWindow)) {
const {
satisfyOn = 'any',
count,
@@ -72,25 +72,25 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
includes = include.map(x => parseSubredditName(x).toLowerCase());
excludes = exclude.map(x => parseSubredditName(x).toLowerCase());
if(includes.length > 0 && excludes.length > 0) {
if (includes.length > 0 && excludes.length > 0) {
// TODO add logger so this can be logged...
// this.logger.warn('include and exclude both specified, exclude will be ignored');
}
satisfiedCount = count;
durVal = duration;
satisfy = satisfyOn
} else if(typeof optWindow === 'number') {
} else if (typeof optWindow === 'number') {
satisfiedCount = optWindow;
} else {
durVal = optWindow as DurationVal;
}
// if count is less than max limit (100) go ahead and just get that many. may result in faster response time for low numbers
if(satisfiedCount !== undefined) {
if (satisfiedCount !== undefined) {
chunkSize = Math.min(chunkSize, satisfiedCount);
}
if(durVal !== undefined) {
if (durVal !== undefined) {
const endTime = dayjs();
if (typeof durVal === 'object') {
duration = dayjs.duration(durVal);
@@ -110,9 +110,9 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
satisfiedEndtime = endTime.subtract(duration.asMilliseconds(), 'milliseconds');
}
if(satisfiedCount === undefined && satisfiedEndtime === undefined) {
if (satisfiedCount === undefined && satisfiedEndtime === undefined) {
throw new Error('window value was not valid');
} else if(satisfy === 'all' && !(satisfiedCount !== undefined && satisfiedEndtime !== undefined)) {
} else if (satisfy === 'all' && !(satisfiedCount !== undefined && satisfiedEndtime !== undefined)) {
// even though 'all' was requested we don't have two criteria so its really 'any' logic
satisfy = 'any';
}
@@ -152,23 +152,16 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
});
}
if(!keepRemoved) {
if (!keepRemoved) {
// snoowrap typings think 'removed' property does not exist on submission
// @ts-ignore
listSlice = listSlice.filter(x => !activityIsRemoved(x));
}
if (satisfiedCount !== undefined && items.length + listSlice.length >= satisfiedCount) {
// satisfied count
if(satisfy === 'any') {
items = items.concat(listSlice).slice(0, satisfiedCount);
break;
}
countOk = true;
}
// its more likely the time criteria is going to be hit before the count criteria
// so check this first
let truncatedItems: Array<Submission | Comment> = [];
if(satisfiedEndtime !== undefined) {
if (satisfiedEndtime !== undefined) {
truncatedItems = listSlice.filter((x) => {
const utc = x.created_utc * 1000;
const itemDate = dayjs(utc);
@@ -177,7 +170,7 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
});
if (truncatedItems.length !== listSlice.length) {
if(satisfy === 'any') {
if (satisfy === 'any') {
// satisfied duration
items = items.concat(truncatedItems);
break;
@@ -186,9 +179,18 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
}
}
if (satisfiedCount !== undefined && items.length + listSlice.length >= satisfiedCount) {
// satisfied count
if (satisfy === 'any') {
items = items.concat(listSlice).slice(0, satisfiedCount);
break;
}
countOk = true;
}
// if we've satisfied everything take whichever is bigger
if(satisfy === 'all' && countOk && timeOk) {
if(satisfiedCount as number > items.length + truncatedItems.length) {
if (satisfy === 'all' && countOk && timeOk) {
if (satisfiedCount as number > items.length + truncatedItems.length) {
items = items.concat(listSlice).slice(0, satisfiedCount);
} else {
items = items.concat(truncatedItems);
@@ -256,14 +258,14 @@ export const renderContent = async (template: string, data: (Submission | Commen
// ...grouped,
// };
// },
permalink: data.permalink,
permalink: `https://reddit.com${data.permalink}`,
botLink: BOT_LINK,
}
if(template.includes('{{item.notes')) {
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;
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;
@@ -379,10 +381,10 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
case 'linkKarma':
const lkCompare = parseGenericValueOrPercentComparison(await authorOpts.linkKarma as string);
let lkMatch;
if(lkCompare.isPercent) {
if (lkCompare.isPercent) {
// @ts-ignore
const tk = author.total_karma as number;
lkMatch = comparisonTextOp(author.link_karma / tk, lkCompare.operator, lkCompare.value/100);
lkMatch = comparisonTextOp(author.link_karma / tk, lkCompare.operator, lkCompare.value / 100);
} else {
lkMatch = comparisonTextOp(author.link_karma, lkCompare.operator, lkCompare.value);
}
@@ -393,10 +395,10 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
case 'commentKarma':
const ckCompare = parseGenericValueOrPercentComparison(await authorOpts.commentKarma as string);
let ckMatch;
if(ckCompare.isPercent) {
if (ckCompare.isPercent) {
// @ts-ignore
const ck = author.total_karma as number;
ckMatch = comparisonTextOp(author.comment_karma / ck, ckCompare.operator, ckCompare.value/100);
ckMatch = comparisonTextOp(author.comment_karma / ck, ckCompare.operator, ckCompare.value / 100);
} else {
ckMatch = comparisonTextOp(author.comment_karma, ckCompare.operator, ckCompare.value);
}
@@ -406,7 +408,7 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
break;
case 'totalKarma':
const tkCompare = parseGenericValueComparison(await authorOpts.totalKarma as string);
if(tkCompare.isPercent) {
if (tkCompare.isPercent) {
throw new SimpleError(`'totalKarma' value on AuthorCriteria cannot be a percentage`);
}
// @ts-ignore
@@ -427,7 +429,12 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
const notePass = () => {
for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) {
const {count = '>= 1', search = 'current', type} = noteCriteria;
const {value, operator, isPercent, extra = ''} = parseGenericValueOrPercentComparison(count);
const {
value,
operator,
isPercent,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const order = extra.includes('asc') ? 'ascending' : 'descending';
switch (search) {
case 'current':
@@ -448,7 +455,7 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
} else {
currCount = 0;
}
if(isPercent) {
if (isPercent) {
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
}
if (comparisonTextOp(currCount, operator, value)) {
@@ -457,11 +464,11 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
}
break;
case 'total':
if(isPercent) {
if(comparisonTextOp(notes.filter(x => x.noteType === type).length / notes.length, operator, value/100)) {
if (isPercent) {
if (comparisonTextOp(notes.filter(x => x.noteType === type).length / notes.length, operator, value / 100)) {
return true;
}
} else if(comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
} else if (comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
return true;
}
}
@@ -526,18 +533,18 @@ export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain =
description,
provider_name,
} = sub.secure_media?.oembed;
switch(provider_name) {
switch (provider_name) {
case 'Spotify':
if(description !== undefined) {
if (description !== undefined) {
let match = description.match(SPOTIFY_PODCAST_AUTHOR_REGEX);
if(match !== null) {
if (match !== null) {
const {author} = match.groups as any;
displayDomain = author;
domainIdents.push(author);
mediaType = 'Podcast';
} else {
match = description.match(SPOTIFY_MUSIC_AUTHOR_REGEX);
if(match !== null) {
if (match !== null) {
const {author, mediaType: mt} = match.groups as any;
displayDomain = author;
domainIdents.push(author);
@@ -545,26 +552,26 @@ export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain =
}
}
}
break;
break;
case 'Anchor FM Inc.':
if(author_name !== undefined) {
if (author_name !== undefined) {
let match = author_name.match(ANCHOR_AUTHOR_REGEX);
if(match !== null) {
if (match !== null) {
const {author} = match.groups as any;
displayDomain = author;
domainIdents.push(author);
mediaType = 'podcast';
}
}
break;
break;
case 'YouTube':
mediaType = 'Video/Audio';
break;
default:
// nah
// nah
}
// handles yt, vimeo, twitter fine
if(displayDomain === '') {
if (displayDomain === '') {
if (author_name !== undefined) {
domainIdents.push(author_name);
if (displayDomain === '') {
@@ -579,21 +586,21 @@ export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain =
}
}
}
if(displayDomain === '') {
if (displayDomain === '') {
// we have media but could not parse stuff for some reason just use url
const u = new URL(sub.url);
displayDomain = u.pathname;
domainIdents.push(u.pathname);
}
provider = provider_name;
} else if(sub.secure_media?.type !== undefined) {
} else if (sub.secure_media?.type !== undefined) {
domainIdents.push(sub.secure_media?.type);
domain = sub.secure_media?.type;
} else {
domain = sub.domain;
}
if(domain === '') {
if (domain === '') {
domain = sub.domain;
}
if (displayDomain === '') {
@@ -603,71 +610,8 @@ export const getAttributionIdentifier = (sub: Submission, useParentMediaDomain =
return {display: displayDomain, domain, aliases: domainIdents, provider, mediaType};
}
export const isItem = (item: Submission | Comment, stateCriteria: TypedActivityStates, logger: Logger): [boolean, SubmissionState|CommentState|undefined] => {
if (stateCriteria.length === 0) {
return [true, undefined];
}
const log = logger.child({leaf: 'Item Check'});
for (const crit of stateCriteria) {
const [pass, passCrit] = (() => {
for (const k of Object.keys(crit)) {
// @ts-ignore
if (crit[k] !== undefined) {
switch(k) {
case 'removed':
const removed = activityIsRemoved(item);
if (removed !== crit['removed']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${removed}`)
return [false, crit];
}
break;
case 'deleted':
const deleted = activityIsDeleted(item);
if (deleted !== crit['deleted']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${deleted}`)
return [false, crit];
}
break;
case 'filtered':
const filtered = activityIsFiltered(item);
if (filtered !== crit['filtered']) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${filtered}`)
return [false, crit];
}
break;
default:
// @ts-ignore
if (item[k] !== undefined) {
// @ts-ignore
if (item[k] !== crit[k]) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item[k]}`)
return [false, crit];
}
} else {
log.warn(`Tried to test for Item property '${k}' but it did not exist`);
}
break;
}
}
}
log.debug(`Passed: ${JSON.stringify(crit)}`);
return [true, crit];
})() as [boolean, SubmissionState|CommentState|undefined];
if (pass) {
return [true, passCrit];
}
}
return [false, undefined];
}
export const activityIsRemoved = (item: Submission|Comment): boolean => {
if(item instanceof Submission) {
export const activityIsRemoved = (item: Submission | Comment): boolean => {
if (item instanceof Submission) {
// when automod filters a post it gets this category
return item.banned_at_utc !== null && item.removed_by_category !== 'automod_filtered';
}
@@ -676,8 +620,8 @@ export const activityIsRemoved = (item: Submission|Comment): boolean => {
return item.banned_at_utc !== null && item.removed;
}
export const activityIsFiltered = (item: Submission|Comment): boolean => {
if(item instanceof Submission) {
export const activityIsFiltered = (item: Submission | Comment): boolean => {
if (item instanceof Submission) {
// when automod filters a post it gets this category
return item.banned_at_utc !== null && item.removed_by_category === 'automod_filtered';
}
@@ -686,9 +630,30 @@ export const activityIsFiltered = (item: Submission|Comment): boolean => {
return item.banned_at_utc !== null && !item.removed;
}
export const activityIsDeleted = (item: Submission|Comment): boolean => {
if(item instanceof Submission) {
export const activityIsDeleted = (item: Submission | Comment): boolean => {
if (item instanceof Submission) {
return item.removed_by_category === 'deleted';
}
return item.author.name === '[deleted]'
}
class ClientSingleton {
client!: Snoowrap;
constructor(client?: Snoowrap) {
if (client !== undefined) {
this.client = client;
}
}
setClient(client: Snoowrap) {
this.client = client;
}
getClient(): Snoowrap {
return this.client;
}
}
// quick little hack to get access to the client without having to pass it all the way down the chain
export const singleton = new ClientSingleton();

216
src/Utils/memoryStore.ts Normal file
View File

@@ -0,0 +1,216 @@
/**
* quick and dirty hack to get access to lru.prune();
*
* this is identical to cache-manager/store/memory.js except for prune function
* */
import Lru from 'lru-cache';
import cloneDeep from 'lodash/cloneDeep';
var isObject = function isObject(value: any) {
return value instanceof Object && value.constructor === Object;
};
function clone(object: object) {
if (typeof object === 'object' && object !== null) {
return cloneDeep(object);
}
return object;
}
/**
* Wrapper for lru-cache.
* NOTE: If you want to use a memory store, you want to write your own to have full
* control over its behavior. E.g., you might need the extra Promise overhead or
* you may want to clone objects a certain way before storing.
*
* @param {object} args - Args passed to underlying lru-cache, plus additional optional args:
* @param {boolean} [args.shouldCloneBeforeSet=true] - Whether to clone the data being stored.
* Default: true
* @param {boolean} [args.usePromises=true] - Whether to enable the use of Promises. Default: true
*/
var memoryStore = function(args: any) {
args = args || {};
var self: any = {};
self.name = 'memory';
var Promise = args.promiseDependency || global.Promise;
self.usePromises = !(typeof Promise === 'undefined' || args.noPromises);
self.shouldCloneBeforeSet = args.shouldCloneBeforeSet !== false; // clone by default
var ttl = args.ttl;
var lruOpts = {
max: args.max || 500,
maxAge: (ttl || ttl === 0) ? ttl * 1000 : null,
dispose: args.dispose,
length: args.length,
stale: args.stale,
updateAgeOnGet: args.updateAgeOnGet || false
};
// @ts-ignore
var lruCache = new Lru(lruOpts);
// @ts-ignore
var setMultipleKeys = function setMultipleKeys(keysValues, maxAge) {
var length = keysValues.length;
var values = [];
for (var i = 0; i < length; i += 2) {
lruCache.set(keysValues[i], keysValues[i + 1], maxAge);
values.push(keysValues[i + 1]);
}
return values;
};
// @ts-ignore
self.set = function(key, value, options, cb) {
if (self.shouldCloneBeforeSet) {
value = clone(value);
}
if (typeof options === 'function') {
cb = options;
options = {};
}
options = options || {};
var maxAge = (options.ttl || options.ttl === 0) ? options.ttl * 1000 : lruOpts.maxAge;
// @ts-ignore
lruCache.set(key, value, maxAge);
if (cb) {
process.nextTick(cb.bind(null, null));
} else if (self.usePromises) {
return Promise.resolve(value);
}
};
self.mset = function() {
var args = Array.prototype.slice.apply(arguments);
var cb;
var options = {};
if (typeof args[args.length - 1] === 'function') {
cb = args.pop();
}
if (args.length % 2 > 0 && isObject(args[args.length - 1])) {
options = args.pop();
}
// @ts-ignore
var maxAge = (options.ttl || options.ttl === 0) ? options.ttl * 1000 : lruOpts.maxAge;
var values = setMultipleKeys(args, maxAge);
if (cb) {
process.nextTick(cb.bind(null, null));
} else if (self.usePromises) {
return Promise.resolve(values);
}
};
// @ts-ignore
self.get = function(key, options, cb) {
if (typeof options === 'function') {
cb = options;
}
var value = lruCache.get(key);
if (cb) {
process.nextTick(cb.bind(null, null, value));
} else if (self.usePromises) {
return Promise.resolve(value);
} else {
return value;
}
};
self.mget = function() {
var args = Array.prototype.slice.apply(arguments);
var cb;
var options = {};
if (typeof args[args.length - 1] === 'function') {
cb = args.pop();
}
if (isObject(args[args.length - 1])) {
options = args.pop();
}
var values = args.map(function(key) {
return lruCache.get(key);
});
if (cb) {
process.nextTick(cb.bind(null, null, values));
} else if (self.usePromises) {
return Promise.resolve(values);
} else {
return values;
}
};
self.del = function() {
var args = Array.prototype.slice.apply(arguments);
var cb;
var options = {};
if (typeof args[args.length - 1] === 'function') {
cb = args.pop();
}
if (isObject(args[args.length - 1])) {
options = args.pop();
}
if (Array.isArray(args[0])) {
args = args[0];
}
args.forEach(function(key) {
lruCache.del(key);
});
if (cb) {
process.nextTick(cb.bind(null, null));
} else if (self.usePromises) {
return Promise.resolve();
}
};
// @ts-ignore
self.reset = function(cb) {
lruCache.reset();
if (cb) {
process.nextTick(cb.bind(null, null));
} else if (self.usePromises) {
return Promise.resolve();
}
};
// @ts-ignore
self.keys = function(cb) {
var keys = lruCache.keys();
if (cb) {
process.nextTick(cb.bind(null, null, keys));
} else if (self.usePromises) {
return Promise.resolve(keys);
} else {
return keys;
}
};
// @ts-ignore
self.prune = function(cb) {
lruCache.prune();
if (cb) {
process.nextTick(cb.bind(null, null));
} else if (self.usePromises) {
return Promise.resolve();
}
}
return self;
};
export const create = (args: any) => memoryStore(args);

View File

@@ -41,6 +41,14 @@ preRunCmd.allowUnknownOption();
const program = new Command();
(async function () {
let app: App;
let errorReason: string | undefined;
process.on('SIGTERM', async () => {
if(app !== undefined) {
await app.onTerminate(errorReason);
}
process.exit(errorReason === undefined ? 0 : 1);
});
try {
let runCommand = program
@@ -77,11 +85,12 @@ const program = new Command();
if (redirectUri === undefined) {
logger.warn(`No 'redirectUri' found in arg/env. Bot will still run but web interface will not be accessible.`);
}
const server = createWebServer(config);
await server;
const [server, bot] = createWebServer(config);
app = bot;
await server();
}
} else {
const app = new App(config);
app = new App(config);
await app.buildManagers();
await app.runManagers();
}
@@ -103,7 +112,7 @@ const program = new Command();
.action(async (activityIdentifier, type, commandOptions = {}) => {
const config = buildOperatorConfigWithDefaults(await parseOperatorConfigFromSources(commandOptions));
const {checks = []} = commandOptions;
const app = new App(config);
app = new App(config);
let a;
const commentId = commentReg(activityIdentifier);
@@ -164,7 +173,7 @@ const program = new Command();
const config = buildOperatorConfigWithDefaults(await parseOperatorConfigFromSources(opts));
const {checks = []} = opts;
const {subreddits: {names}} = config;
const app = new App(config);
app = new App(config);
await app.buildManagers(names);
@@ -193,6 +202,7 @@ const program = new Command();
}
console.log(err);
}
errorReason = `Application crashed due to an uncaught error: ${err.message}`;
process.kill(process.pid, 'SIGTERM');
}
}());

View File

@@ -22,9 +22,10 @@ import SimpleError from "./Utils/SimpleError";
import InvalidRegexError from "./Utils/InvalidRegexError";
import {constants, promises} from "fs";
import {cacheOptDefaults} from "./Common/defaults";
import cacheManager from "cache-manager";
import cacheManager, {Cache} from "cache-manager";
import redisStore from "cache-manager-redis-store";
import crypto from "crypto";
import {create as createMemoryStore} from './Utils/memoryStore';
const {format} = winston;
const {combine, printf, timestamp, label, splat, errors} = format;
@@ -766,6 +767,8 @@ export const permissions = [
'identity',
'modcontributors',
'modflair',
'modmail',
'privatemessages',
'modposts',
'modself',
'mysubreddits',
@@ -874,8 +877,12 @@ export const cacheStats = (): ResourceStats => {
return {
author: {requests: 0, miss: 0},
authorCrit: {requests: 0, miss: 0},
itemCrit: {requests: 0, miss: 0},
content: {requests: 0, miss: 0},
userNotes: {requests: 0, miss: 0},
submission: {requests: 0, miss: 0},
comment: {requests: 0, miss: 0},
commentCheck: {requests: 0, miss: 0}
};
}
@@ -893,11 +900,11 @@ export const buildCacheOptionsFromProvider = (provider: CacheProvider | any): Ca
}
}
export const createCacheManager = (options: CacheOptions) => {
export const createCacheManager = (options: CacheOptions): Cache => {
const {store, max, ttl = 60, host = 'localhost', port, auth_pass, db} = options;
switch (store) {
case 'none':
return undefined;
return cacheManager.caching({store: 'none', max, ttl});
case 'redis':
return cacheManager.caching({
store: redisStore,
@@ -909,7 +916,8 @@ export const createCacheManager = (options: CacheOptions) => {
});
case 'memory':
default:
return cacheManager.caching({store: 'memory', max, ttl});
//return cacheManager.caching({store: 'memory', max, ttl});
return cacheManager.caching({store: {create: createMemoryStore}, max, ttl, shouldCloneBeforeSet: false});
}
}