Compare commits

..

1 Commits

Author SHA1 Message Date
FoxxMD
2028843714 refactor(image): Begin moving image comparison logic into own service 2022-08-18 15:28:50 -04:00
68 changed files with 815 additions and 2260 deletions

View File

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

View File

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

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ They are responsible for configuring the software at a high-level and managing a
# Overview
CM is composed of two applications that operate independently but are packaged together such that they act as one piece of software:
CM is composed of two applications that operate indepedently but are packaged together such that they act as one piece of software:
* **Server** -- Responsible for **running the bot(s)** and providing an API to retrieve information on and interact with them EX start/stop bot, reload config, retrieve operational status, etc.
* **Client** -- Responsible for serving the **web interface** and handling the bot oauth authentication flow between operators and subreddits/bots.

View File

@@ -136,8 +136,6 @@ You will need have this information available:
See the [**example minimum configuration** below.](#minimum-config)
This configuration can also be **generated** by CM if you start CM with **no configuration defined** and visit the web interface.
# Bots
Configured using the `bots` top-level property. Bot configuration can override and specify many more options than are available at the operator-level. Many of these can also set the defaults for each subreddit the bot runs:

View File

@@ -4,7 +4,9 @@ This getting started guide is for **Operators** -- that is, someone who wants to
* [Installation](#installation)
* [Create a Reddit Client](#create-a-reddit-client)
* [Start ContextMod](#start-contextmod)
* [Create a Minimum Configuration](#create-a-minimum-configuration)
* [Local Installation](#local-installation)
* [Docker Installation](#docker-installation)
* [Add a Bot to CM](#add-a-bot-to-cm)
* [Access The Dashboard](#access-the-dashboard)
* [What's Next?](#whats-next)
@@ -17,25 +19,29 @@ Follow the [installation](/docs/operator/installation.md) documentation. It is r
[Create a reddit client](/docs/operator/README.md#provisioning-a-reddit-client)
# Start ContextMod
# Create a Minimum Configuration
Start CM using the example command from your [installation](#installation) and visit http://localhost:8085
Using the information you received in the previous step [create a minimum file configuration](/docs/operator/configuration.md#minimum-configuration) save it as `config.yaml` somewhere.
The First Time Setup page will ask you to input:
# Start ContextMod With Configuration
* Client ID (from [Create a Reddit Client](#create-a-reddit-client))
* Client Secret (from [Create a Reddit Client](#create-a-reddit-client))
* Operator -- this is the username of your main Reddit account.
## Local Installation
**Write Config** and then restart CM. You have now created the [minimum configuration](/docs/operator/configuration.md#minimum-configuration) required to run CM.
If you [installed CM locally](/docs/installation.md#locally) move your configuration file `config.yaml` to the root of the project directory (where `package.json`) is located.
From the root directory run this command to start CM
```
node src/index.js run
```
## Docker Installation
If you [installed CM using Docker](/docs/installation.md#docker-recommended) make note of the directory you saved your minimum configuration to and substitute its full path for `host/path/folder` in the docker command show in the [docker install directions](/docs/operator/installation.md#docker-recommended)
# Add A Bot to CM
You should automatically be directed to the [Bot Invite Helper](/docs/operator/addingBot.md#cm-oauth-helper-recommended) used to authorize and add a Bot to your CM instance.
Follow the directions here and **create an Authorization Invite** at the bottom of the page.
Next, login to Reddit with the account you will be using as the Bot and then visit the **Authorization Invite** link you created. Follow the steps there to finish adding the Bot to your CM instance.
Once CM is up and running use the [CM OAuth Helper](/docs/operator/addingBot.md#cm-oauth-helper-recommended) to add authorize and add a Bot to your CM instance.
# Access The Dashboard
@@ -51,4 +57,4 @@ As an operator you should familiarize yourself with how the [operator configurat
If you are also the moderator of the subreddit the bot will be running you should check out the [moderator getting started guide.](/docs/subreddit/gettingStarted.md#setup-wiki-page)
You might also be interested in these [quick tips for using the web interface](/docs/webInterface.md). Additionally, on the dashboard click the **Help** button at the top of the page to get a guided tour of the dashboard.
You might also be interested in these [quick tips for using the web interface](/docs/webInterface.md)

View File

@@ -8,19 +8,15 @@ ContextMod can be run on almost any operating system but it is recommended to us
PROTIP: Using a container management tool like [Portainer.io CE](https://www.portainer.io/products/community-edition) will help with setup/configuration tremendously.
Images available from these registeries:
* [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod) - `docker.io/foxxmd/context-mod`
* [GHCR](https://github.com/foxxmd/context-mod/pkgs/container/context-mod) - `ghcr.io/foxxmd/context-mod`
### [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod)
An example of starting the container using the [minimum configuration](/docs/operator/configuration.md#minimum-config):
* Bind the directory where your config file, logs, and database are located on your host machine into the container's default `DATA_DIR` by using `-v /host/path/folder:/config`
* Note: **You must do this** or else your configuration will be lost next time your container is updated.
* Expose the web interface using the container port `8085`
```
docker run -d -v /host/path/folder:/config -p 8085:8085 ghcr.io/foxxmd/context-mod:latest
docker run -d -v /host/path/folder:/config -p 8085:8085 foxxmd/context-mod
```
The location of `DATA_DIR` in the container can be changed by passing it as an environmental variable EX `-e "DATA_DIR=/home/abc/config`
@@ -37,7 +33,7 @@ To get the UID and GID for the current user run these commands from a terminal:
* `id -g` -- prints GID
```
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 ghcr.io/foxxmd/context-mod:latest
docker run -d -v /host/path/folder:/config -p 8085:8085 -e PUID=1000 -e PGID=1000 foxxmd/context-mod
```
## Locally

View File

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

View File

@@ -29,7 +29,6 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
* [List of Actions](#list-of-actions)
* [Approve](#approve)
* [Ban](#ban)
* [Submission](#submission)
* [Comment](#comment)
* [Contributor (Add/Remove)](#contributor)
* [Dispatch/Delay](#dispatch)
@@ -495,30 +494,11 @@ actions:
### Comment
Reply to an Activity with a comment. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FCommentActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
Reply to the Activity being processed with a comment. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FCommentActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
* If the Activity is a Submission the comment is a top-level reply
* If the Activity is a Comment the comment is a child reply
#### Templating
`content` can be [templated](#templating) and use [URL Tokens](#url-tokens)
#### Targets
Optionally, specify the Activity CM should reply to. **When not specified CM replies to the Activity being processed using `self`**
Valid values: `self`, `parent`, or a Reddit permalink.
`self` and `parent` are special targets that are relative to the Activity being processed:
* When the Activity being processed is a **Submission** => `parent` logs a warning and does nothing
* When the Activity being processed is a **Comment**
* `self` => reply to Comment
* `parent` => make a top-level Comment in the **Submission** the Comment belong to
If target is not self/parent then CM assumes the value is a **reddit permalink** and will attempt to make a Comment to that Activity
```yaml
actions:
- kind: comment
@@ -526,71 +506,7 @@ actions:
distinguish: boolean # distinguish as a mod
sticky: boolean # sticky comment
lock: boolean # lock the comment after creation
targets: string # 'self' or 'parent' or 'https://reddit.com/r/someSubreddit/21nfdi....'
```
### Submission
Create a Submission [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FSubmissionActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
The Submission type, Link or Self-Post, is determined based on the presence of `url` in the action's configuration.
```yaml
actions:
- kind: submission
title: string # required, the title of the submission. can be templated.
content: string # the body of the submission. can be templated
url: string # if specified the submission will be a Link Submission. can be templated
distinguish: boolean # distinguish as a mod
sticky: boolean # sticky comment
lock: boolean # lock the comment after creation
nsfw: boolean # mark submission as NSFW
spoiler: boolean # mark submission as a spoiler
flairId: string # flair template id for submission
flairText: string # flair text for submission
targets: string # 'self' or a subreddit name IE mealtimevideos
```
#### Templating
`content`,`url`, and `title` can be [templated](#templating) and use [URL Tokens](#url-tokens)
TIP: To create a Link Submission pointing to the Activity currently being processed use
```yaml
actions:
- kind: submission
url: {{item.permalink}}
# ...
```
#### Targets
Optionally, specify the Subreddit the Submission should be made in. **When not specified CM uses `self`**
Valid values: `self` or Subreddit Name
* `self` => (**Default**) Create Submission in the same Subreddit of the Activity being processed
* Subreddit Name => Create Submission in given subreddit IE `mealtimevideos`
* Your bot must be able to access and be able to post in the given subreddit
Example:
```yaml
actions:
- kind: comment
targets: mealtimevideos
```
To post to multiple subreddits use a list:
```yaml
actions:
- kind: comment
targets:
- self
- mealtimevideos
- anotherSubreddit
```
### Contributor
@@ -692,16 +608,15 @@ Some other things to note:
* If the `to` property is not specified then the message is sent to the Author of the Activity being processed
* `to` may be a **User** (u/aUser) or a **Subreddit** (r/aSubreddit)
* `to` **cannot** be a Subreddit when `asSubreddit: true` -- IE cannot send subreddit-to-subreddit messages
* TIP: `to` can be templated -- to send a message to the subreddit the Activity being processed is in use `'r/{{item.subreddit}}'`
* `content` and `title` can be [templated](#templating) and use [URL Tokens](#url-tokens)
* `content` can be [templated](#templating) and use [URL Tokens](#url-tokens)
```yaml
actions:
- kind: message
asSubreddit: true
content: 'A message sent as the subreddit' # can be templated
title: 'Title of the message' # can be templated
to: 'u/aUser' # do not specify 'to' in order default to sending to Author of Activity being processed. Can also be templated
content: 'A message sent as the subreddit'
title: 'Title of the message'
to: 'u/aUser' # do not specify 'to' in order default to sending to Author of Activity being processed
```
### Remove

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,12 @@
import Action, {ActionJson, ActionOptions} from "./index";
import {Comment, VoteableContent} from "snoowrap";
import {Comment} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {renderContent} from "../Utils/SnoowrapUtils";
import {ActionProcessResult, Footer, RequiredRichContent, RichContent, RuleResult} from "../Common/interfaces";
import {asComment, asSubmission, parseRedditThingsFromLink, truncateStringToLength} from "../util";
import {truncateStringToLength} from "../util";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infrastructure/Atomic";
import {CMError} from "../Utils/Errors";
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
import {ActionTypes} from "../Common/Infrastructure/Atomic";
export class CommentAction extends Action {
content: string;
@@ -17,7 +14,6 @@ export class CommentAction extends Action {
sticky: boolean = false;
distinguish: boolean = false;
footer?: false | string;
targets: ArbitraryActionTarget[]
constructor(options: CommentActionOptions) {
super(options);
@@ -27,139 +23,71 @@ export class CommentAction extends Action {
sticky = false,
distinguish = false,
footer,
targets = ['self']
} = options;
this.footer = footer;
this.content = content;
this.lock = lock;
this.sticky = sticky;
this.distinguish = distinguish;
if (!Array.isArray(targets)) {
this.targets = [targets];
} else {
this.targets = targets;
}
}
getKind(): ActionTypes {
return 'comment';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const body = await this.renderContent(this.content, item, ruleResults, actionResults) as string;
const content = await this.resources.getContent(this.content, item.subreddit);
const body = await renderContent(content, item, ruleResults, this.resources.userNotes);
const footer = await this.resources.renderFooter(item, this.footer);
const footer = await this.resources.generateFooter(item, this.footer);
const renderedContent = `${body}${footer}`;
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
let allErrors = true;
const targetResults: string[] = [];
if(item.archived) {
this.logger.warn('Cannot comment because Item is archived');
return {
dryRun,
success: false,
result: 'Cannot comment because Item is archived'
};
}
const touchedEntities = [];
for (const target of this.targets) {
let targetItem = item;
let targetIdentifier = target;
if (target === 'parent') {
if (asSubmission(item)) {
const noParent = `[Parent] Submission ${item.name} does not have a parent`;
this.logger.warn(noParent);
targetResults.push(noParent);
continue;
}
targetItem = await this.resources.getActivity(this.client.getSubmission(item.link_id));
} else if (target !== 'self') {
const redditThings = parseRedditThingsFromLink(target);
let id = '';
try {
if (redditThings.comment !== undefined) {
id = redditThings.comment.id;
targetIdentifier = `Permalink Comment ${id}`
// @ts-ignore
await this.resources.getActivity(this.client.getSubmission(redditThings.submission.id));
targetItem = await this.resources.getActivity(this.client.getComment(redditThings.comment.id));
} else if (redditThings.submission !== undefined) {
id = redditThings.submission.id;
targetIdentifier = `Permalink Submission ${id}`
targetItem = await this.resources.getActivity(this.client.getSubmission(redditThings.submission.id));
} else {
targetResults.push(`[Permalink] Could not parse ${target} as a reddit permalink`);
continue;
}
} catch (err: any) {
targetResults.push(`[${targetIdentifier}] error occurred while fetching activity: ${err.message}`);
this.logger.warn(new CMError(`[${targetIdentifier}] error occurred while fetching activity`, {cause: err}));
continue;
}
}
if (targetItem.archived) {
const archived = `[${targetIdentifier}] Cannot comment because Item is archived`;
this.logger.warn(archived);
targetResults.push(archived);
continue;
}
let modifiers = [];
let reply: Comment;
if (!dryRun) {
// @ts-ignore
reply = await targetItem.reply(renderedContent);
// add to recent so we ignore activity when/if it is discovered by polling
await this.resources.setRecentSelf(reply);
touchedEntities.push(reply);
}
if (this.lock && targetItem.can_mod_post) {
if (!targetItem.can_mod_post) {
this.logger.warn(`[${targetIdentifier}] Cannot lock because bot is not a moderator`);
} else {
modifiers.push('Locked');
if (!dryRun) {
// snoopwrap typing issue, thinks comments can't be locked
// @ts-ignore
await reply.lock();
}
}
}
if (this.distinguish) {
if (!targetItem.can_mod_post) {
this.logger.warn(`[${targetIdentifier}] Cannot lock Distinguish/Sticky because bot is not a moderator`);
} else {
modifiers.push('Distinguished');
if (this.sticky) {
modifiers.push('Stickied');
}
if (!dryRun) {
// @ts-ignore
await reply.distinguish({sticky: this.sticky});
}
}
}
const modifierStr = modifiers.length === 0 ? '' : ` == ${modifiers.join(' | ')} == =>`;
let modifiers = [];
let reply: Comment;
if(!dryRun) {
// @ts-ignore
targetResults.push(`${targetIdentifier}${modifierStr} created Comment ${dryRun ? 'DRYRUN' : (reply as SnoowrapActivity).name}`)
allErrors = false;
reply = await item.reply(renderedContent);
// add to recent so we ignore activity when/if it is discovered by polling
await this.resources.setRecentSelf(reply);
touchedEntities.push(reply);
}
if (this.lock) {
modifiers.push('Locked');
if (!dryRun) {
// snoopwrap typing issue, thinks comments can't be locked
// @ts-ignore
await reply.lock();
}
}
if (this.distinguish && !dryRun) {
modifiers.push('Distinguished');
if(this.sticky) {
modifiers.push('Stickied');
}
if(!dryRun) {
// @ts-ignore
await reply.distinguish({sticky: this.sticky});
}
}
const modifierStr = modifiers.length === 0 ? '' : `[${modifiers.join(' | ')}]`;
return {
dryRun,
success: !allErrors,
result: `${targetResults.join('\n')}${truncateStringToLength(100)(body)}`,
success: true,
result: `${modifierStr}${truncateStringToLength(100)(body)}`,
touchedEntities,
data: {
body,
bodyShort: truncateStringToLength(100)(body),
comments: targetResults,
commentsFormatted: targetResults.map(x => `* ${x}`).join('\n')
}
};
}
@@ -169,8 +97,7 @@ export class CommentAction extends Action {
lock: this.lock,
sticky: this.sticky,
distinguish: this.distinguish,
footer: this.footer,
targets: this.targets,
footer: this.footer
}
}
}
@@ -188,21 +115,6 @@ export interface CommentActionConfig extends RequiredRichContent, Footer {
* Distinguish the comment after creation?
* */
distinguish?: boolean,
/**
* Specify where this comment should be made
*
* Valid values: 'self' | 'parent' | [reddit permalink]
*
* 'self' and 'parent' are special targets that are relative to the Activity being processed:
* * When Activity is Submission => 'parent' does nothing
* * When Activity is Comment
* * 'self' => reply to Activity
* * 'parent' => make a top-level comment in the Submission the Comment is in
*
* If target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity
* */
targets?: ArbitraryActionTarget | ArbitraryActionTarget[]
}
export interface CommentActionOptions extends CommentActionConfig, ActionOptions {
@@ -212,5 +124,5 @@ export interface CommentActionOptions extends CommentActionConfig, ActionOptions
* Reply to the Activity. For a submission the reply will be a top-level comment.
* */
export interface CommentActionJson extends CommentActionConfig, ActionJson {
kind: 'comment'
kind: 'comment'
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes, ModUserNoteLabel} from "../Common/Infrastructure/Atomic";
import {ModNote} from "../Subreddit/ModNotes/ModNote";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class ModNoteAction extends Action {
@@ -40,12 +39,13 @@ export class ModNoteAction extends Action {
}
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const modLabel = this.type !== undefined ? toModNoteLabel(this.type) : undefined;
const renderedContent = await this.renderContent(this.content, item, ruleResults, actionResults);
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
// TODO see what changes are made for bulk fetch of notes before implementing this

View File

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

View File

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

View File

@@ -1,329 +0,0 @@
import Action, {ActionJson, ActionOptions} from "./index";
import {Comment, SubmitLinkOptions, SubmitSelfPostOptions, VoteableContent} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {renderContent} from "../Utils/SnoowrapUtils";
import {ActionProcessResult, Footer, RequiredRichContent, RichContent, RuleResult} from "../Common/interfaces";
import {asComment, asSubmission, parseRedditEntity, parseRedditThingsFromLink, sleep, truncateStringToLength} from "../util";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infrastructure/Atomic";
import {CMError} from "../Utils/Errors";
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
import Subreddit from "snoowrap/dist/objects/Subreddit";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class SubmissionAction extends Action {
content?: string;
lock: boolean = false;
sticky: boolean = false;
distinguish: boolean = false;
spoiler: boolean = false;
nsfw: boolean = false;
flairId?: string
flairText?: string
url?: string
title: string
footer?: false | string;
targets: ('self' | string)[]
constructor(options: SubmissionActionOptions) {
super(options);
const {
content,
lock = false,
sticky = false,
spoiler = false,
distinguish = false,
nsfw = false,
flairText,
flairId,
footer,
url,
title,
targets = ['self']
} = options;
this.footer = footer;
this.content = content;
this.lock = lock;
this.sticky = sticky;
if(this.sticky) {
this.distinguish = sticky;
} else {
this.distinguish = distinguish;
}
this.spoiler = spoiler;
this.nsfw = nsfw;
this.flairText = flairText;
this.flairId = flairId;
this.url = url;
this.title = title;
if (!Array.isArray(targets)) {
this.targets = [targets];
} else {
this.targets = targets;
}
}
getKind(): ActionTypes {
return 'submission';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const title = await this.renderContent(this.title, item, ruleResults, actionResults) as string;
this.logger.verbose(`Title: ${title}`);
const url = await this.renderContent(this.url, item, ruleResults, actionResults);
this.logger.verbose(`URL: ${url !== undefined ? url : '[No URL]'}`);
const body = await this.renderContent(this.content, item, ruleResults, actionResults);
let renderedContent: string | undefined = undefined;
if(body !== undefined) {
const footer = await this.resources.renderFooter(item, this.footer);
renderedContent = `${body}${footer}`;
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
} else {
this.logger.verbose(`Contents: [No Body]`);
}
let allErrors = true;
const targetResults: string[] = [];
const touchedEntities = [];
let submittedOnce = false;
for (const targetVal of this.targets) {
//
if(submittedOnce) {
// delay submissions by 3 seconds (on previous successful call)
// to try to spread out load
await sleep(3000);
}
let target: Subreddit = item.subreddit;
let targetIdentifier = targetVal;
if (targetVal !== 'self') {
const subredditVal = parseRedditEntity(targetVal);
try {
target = await this.resources.getSubreddit(subredditVal.name);
targetIdentifier = `[Subreddit ${target.display_name}]`;
} catch (err: any) {
targetResults.push(`[${targetIdentifier}] error occurred while fetching subreddit: ${err.message}`);
if(!err.logged) {
this.logger.warn(new CMError(`[${targetIdentifier}] error occurred while fetching subreddit`, {cause: err}));
}
continue;
}
}
// TODO check if we can post in subreddit
let modifiers = [];
let post: Submission | undefined;
if (!dryRun) {
let opts: SubmitLinkOptions | SubmitSelfPostOptions;
let type: 'self' | 'link';
const genericOpts = {
title,
subredditName: target.display_name,
nsfw: this.nsfw,
spoiler: this.spoiler,
flairId: this.flairId,
flairText: this.flairText,
};
if(url !== undefined) {
type = 'link';
opts = {
...genericOpts,
url,
};
if(renderedContent !== undefined) {
// @ts-ignore
linkOpts.text = renderedContent;
}
} else {
type = 'self';
opts = {
...genericOpts,
text: renderedContent,
}
}
// @ts-ignore
post = await this.tryPost(type, target, opts);
await this.resources.setRecentSelf(post as Submission);
if(post !== undefined) {
touchedEntities.push(post);
}
}
if (this.lock) {
if (post !== undefined && !post.can_mod_post) {
this.logger.warn(`[${targetIdentifier}] Cannot lock because bot is not a moderator`);
} else {
modifiers.push('Locked');
if (!dryRun && post !== undefined) {
// snoopwrap typing issue, thinks comments can't be locked
// @ts-ignore
await post.lock();
}
}
}
if (this.distinguish) {
if (post !== undefined && !post.can_mod_post) {
this.logger.warn(`[${targetIdentifier}] Cannot Distinguish/Sticky because bot is not a moderator`);
} else {
modifiers.push('Distinguished');
if (this.sticky) {
modifiers.push('Stickied');
}
if (!dryRun && post !== undefined) {
// @ts-ignore
await post.distinguish({sticky: this.sticky});
}
}
}
const modifierStr = modifiers.length === 0 ? '' : ` == ${modifiers.join(' | ')} == =>`;
const targetSummary = `${targetIdentifier} ${modifierStr} created Submission ${dryRun ? 'DRYRUN' : (post as SnoowrapActivity).name}`;
// @ts-ignore
targetResults.push(targetSummary)
this.logger.verbose(targetSummary);
allErrors = false;
}
return {
dryRun,
success: !allErrors,
result: `${targetResults.join('\n')}${this.url !== undefined ? `\nURL: ${this.url}` : ''}${body !== undefined ? truncateStringToLength(100)(body) : ''}`,
touchedEntities,
data: {
body,
bodyShort: body !== undefined ? truncateStringToLength(100)(body) : '',
submissions: targetResults.map(x => `* ${x}`).join('\n')
}
};
}
// @ts-ignore
protected async tryPost(type: 'self' | 'link', target: Subreddit, data: SubmitLinkOptions | SubmitSelfPostOptions, maxAttempts = 2): Promise<Submission> {
let post: Submission | undefined;
let error: any;
for (let i = 0; i <= maxAttempts; i++) {
try {
if (type === 'self') {
// @ts-ignore
post = await target.submitSelfpost(data as SubmitSelfPostOptions);
} else {
// @ts-ignore
post = await target.submitLink(data as SubmitLinkOptions);
}
break;
} catch (e: any) {
if (e.message.includes('RATELIMIT')) {
// Looks like you've been doing that a lot. Take a break for 5 seconds before trying again
await sleep(5000);
error = e;
} else {
throw e;
}
}
}
if (error !== undefined) {
throw error;
}
// @ts-ignore
return post;
}
protected getSpecificPremise(): object {
return {
content: this.content,
lock: this.lock,
sticky: this.sticky,
spoiler: this.spoiler,
distinguish: this.distinguish,
nsfw: this.nsfw,
flairId: this.flairId,
flairText: this.flairText,
url: this.url,
text: this.content !== undefined ? truncateStringToLength(50)(this.content) : undefined,
footer: this.footer,
targets: this.targets,
}
}
}
export interface SubmissionActionConfig extends RichContent, Footer {
/**
* Lock the Submission after creation?
* */
lock?: boolean,
/**
* Sticky the Submission after creation?
* */
sticky?: boolean,
nsfw?: boolean
spoiler?: boolean
/**
* The title of this Submission.
*
* Templated the same as **content**
* */
title: string
/**
* If Submission should be a Link, the URL to use
*
* Templated the same as **content**
*
* PROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value
* */
url?: string
/**
* Flair template to apply to this Submission
* */
flairId?: string
/**
* Flair text to apply to this Submission
* */
flairText?: string
/**
* Distinguish as Mod after creation?
* */
distinguish?: boolean
/**
* Specify where this Submission should be made
*
* Valid values: 'self' | [subreddit]
*
* * 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed
* * [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos
* */
targets?: ('self' | string) | ('self' | string)[]
}
export interface SubmissionActionOptions extends SubmissionActionConfig, ActionOptions {
}
/**
* Reply to the Activity. For a submission the reply will be a top-level comment.
* */
export interface SubmissionActionJson extends SubmissionActionConfig, ActionJson {
kind: 'submission'
}

View File

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

View File

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

View File

@@ -8,7 +8,6 @@ import {ActionProcessResult, RuleResult} from "../Common/interfaces";
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
import {runCheckOptions} from "../Subreddit/Manager";
import {ActionTypes, UserNoteType} from "../Common/Infrastructure/Atomic";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
export class UserNoteAction extends Action {
@@ -28,9 +27,10 @@ export class UserNoteAction extends Action {
return 'usernote';
}
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], actionResults: ActionResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
const dryRun = this.getRuntimeAwareDryrun(options);
const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string);
const content = await this.resources.getContent(this.content, item.subreddit);
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`);
if (!this.allowDuplicate) {

View File

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

View File

@@ -386,7 +386,7 @@ class Bot implements BotInstanceFunctions {
async testClient(initial = true) {
try {
// @ts-ignore
const user = await this.client.getMe().fetch();
const user = this.client.getMe().fetch();
this.logger.info('Test API call successful');
return user;
} catch (err: any) {
@@ -680,7 +680,6 @@ class Bot implements BotInstanceFunctions {
databaseConfig: {
retention = undefined
} = {},
wikiConfig = this.wikiLocation,
} = override || {};
const subRepo = this.database.getRepository(SubredditEntity)
@@ -718,7 +717,7 @@ class Bot implements BotInstanceFunctions {
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {
dryRun: this.dryRun,
sharedStreams: this.sharedStreams,
wikiLocation: wikiConfig,
wikiLocation: this.wikiLocation,
botName: this.botName as string,
maxWorkers: this.maxWorkers,
filterCriteriaDefaults: this.filterCriteriaDefaults,

View File

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

View File

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

View File

@@ -0,0 +1,199 @@
import {Logger} from "winston";
import {SubredditResources} from "../Subreddit/SubredditResources";
import {StrongImageDetection} from "./interfaces";
import ImageData from "./ImageData";
import {bitsToHexLength, mergeArr} from "../util";
import {CMError} from "../Utils/Errors";
import {ImageHashCacheData} from "./Infrastructure/Atomic";
import leven from "leven";
export interface CompareImageOptions {
config?: StrongImageDetection
}
export interface ThresholdResults {
withinHard: boolean | undefined,
withinSoft: boolean | undefined
}
export class ImageComparisonService {
protected reference!: ImageData
protected resources: SubredditResources;
protected logger: Logger;
protected detectionConfig: StrongImageDetection;
constructor(resources: SubredditResources, logger: Logger, config: StrongImageDetection) {
this.resources = resources;
this.logger = logger.child({labels: ['Image Detection']}, mergeArr);
this.detectionConfig = config;
}
async setReference(img: ImageData, options?: CompareImageOptions) {
this.reference = img;
const {config = this.detectionConfig} = options || {};
try {
this.reference.setPreferredResolutionByWidth(800);
if (config.hash.enable) {
if (config.hash.ttl !== undefined) {
const refHash = await this.resources.getImageHash(this.reference);
if (refHash === undefined) {
await this.reference.hash(config.hash.bits);
await this.resources.setImageHash(this.reference, config.hash.ttl);
} else if (refHash.original.length !== bitsToHexLength(config.hash.bits)) {
this.logger.warn('Reference image hash length did not correspond to bits specified in config. Recomputing...');
await this.reference.hash(config.hash.bits);
await this.resources.setImageHash(this.reference, config.hash.ttl);
} else {
this.reference.setFromHashCache(refHash);
}
} else {
await this.reference.hash(config.hash.bits);
}
}
} catch (err: any) {
throw new CMError('Could not set reference image due to an error', {cause: err});
}
}
compareDiffWithThreshold(diff: number, options?: CompareImageOptions): ThresholdResults {
const {
config: {
hash: {
hardThreshold = 5,
softThreshold = undefined,
} = {},
} = this.detectionConfig
} = options || {};
let hard: boolean | undefined;
let soft: boolean | undefined;
if ((null !== hardThreshold && undefined !== hardThreshold)) {
hard = diff <= hardThreshold;
if (hard) {
return {withinHard: hard, withinSoft: hard};
}
}
if ((null !== softThreshold && undefined !== softThreshold)) {
soft = diff <= softThreshold;
}
return {withinHard: hard, withinSoft: soft};
}
async compareWithCandidate(candidate: ImageData, options?: CompareImageOptions) {
const {config = this.detectionConfig} = options || {};
if (config.hash.enable) {
await this.compareCandidateHash(candidate, options);
}
}
async compareCandidateHash(candidate: ImageData, options?: CompareImageOptions) {
const {config = this.detectionConfig} = options || {};
let compareHash: Required<ImageHashCacheData> | undefined;
if (config.hash.ttl !== undefined) {
compareHash = await this.resources.getImageHash(candidate);
}
if (compareHash === undefined) {
compareHash = await candidate.hash(config.hash.bits);
if (config.hash.ttl !== undefined) {
await this.resources.setImageHash(candidate, config.hash.ttl);
}
} else {
candidate.setFromHashCache(compareHash);
}
let diff = await this.compareImageHashes(this.reference, candidate, options);
let threshRes = this.compareDiffWithThreshold(diff, options);
if(threshRes.withinSoft !== true && threshRes.withinHard !== true) {
// up to this point we rely naively on hashes that were:
//
// * from cache/db for which we do not have resolutions stored (maybe fix this??)
// * hashes generated from PREVIEWS from reddit that should be the same *width*
//
// we don't have control over how reddit resizes previews or the quality of the previews
// so if we don't get a match using our initial naive, but cpu/data lite approach,
// then we need to check original sources to see if it's possible there has been resolution/cropping trickery
if(this.reference.isMaybeCropped(candidate)) {
const [normalizedRefSharp, normalizedCandidateSharp, width, height] = await this.reference.normalizeImagesForComparison('pixel', candidate, false);
const normalizedRef = new ImageData({width, height, path: this.reference.path});
normalizedRef.sharpImg = normalizedRefSharp;
const normalizedCandidate = new ImageData({width, height, path: candidate.path});
normalizedCandidate.sharpImg = normalizedCandidateSharp;
const normalDiff = await this.compareImageHashes(normalizedRef, normalizedCandidate, options);
let normalizedThreshRes = this.compareDiffWithThreshold(normalDiff, options);
}
}
/* // return image if hard is defined and diff is less
if (null !== config.hash.hardThreshold && diff <= config.hash.hardThreshold) {
return x;
}
// hard is either not defined or diff was greater than hard
// if soft is defined
if (config.hash.softThreshold !== undefined) {
// and diff is greater than soft allowance
if (diff > config.hash.softThreshold) {
// not similar enough
return null;
}
// similar enough, will continue on to pixel (if enabled!)
} else {
// only hard was defined and did not pass
return null;
}*/
}
async compareImageHashes(reference: ImageData, candidate: ImageData, options?: CompareImageOptions) {
const {config = this.detectionConfig} = options || {};
const {
hash: {
bits = 16,
} = {},
} = config;
let refHash = await reference.hash(bits);
let compareHash = await candidate.hash(bits);
if (compareHash.original.length !== refHash.original.length) {
this.logger.warn(`Hash lengths were not the same! Will need to recompute compare hash to match reference.\n\nReference: ${reference.basePath} has is ${refHash.original.length} char long | Comparing: ${candidate.basePath} has is ${compareHash} ${compareHash.original.length} long`);
refHash = await reference.hash(bits, true, true);
compareHash = await candidate.hash(bits, true, true);
}
let diff: number;
const odistance = leven(refHash.original, compareHash.original);
diff = (odistance / refHash.original.length) * 100;
// compare flipped hash if it exists
// if it has less difference than normal comparison then the image is probably flipped (or so different it doesn't matter)
if (compareHash.flipped !== undefined) {
const fdistance = leven(refHash.original, compareHash.flipped);
const fdiff = (fdistance / refHash.original.length) * 100;
if (fdiff < diff) {
diff = fdiff;
}
}
return diff;
}
async compareCandidatePixel() {
// TODO
}
async compareImagePixels() {
// TODO
}
}

View File

@@ -42,8 +42,8 @@ class ImageData {
return await (await this.sharp()).clone().toFormat(format).toBuffer();
}
async hash(bits: number = 16, useVariantIfPossible = true): Promise<Required<ImageHashCacheData>> {
if (this.hashResult === undefined || this.hashResultFlipped === undefined) {
async hash(bits: number = 16, useVariantIfPossible = true, force = false): Promise<Required<ImageHashCacheData>> {
if (force || (this.hashResult === undefined || this.hashResultFlipped === undefined)) {
let ref: ImageData | undefined;
if (useVariantIfPossible && this.preferredResolution !== undefined) {
ref = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
@@ -182,6 +182,25 @@ class ImageData {
return this.width === otherImage.width && this.height === otherImage.height;
}
isMaybeCropped(otherImage: ImageData, allowDiff = 10): boolean {
if (!this.hasDimensions || !otherImage.hasDimensions) {
return false;
}
const refWidth = this.width as number;
const refHeight = this.height as number;
const oWidth = otherImage.width as number;
const oHeight = otherImage.height as number;
const sWidth = refWidth <= oWidth ? refWidth : oWidth;
const sHeight = refHeight <= oHeight ? refHeight : oHeight;
const widthDiff = sWidth / (sWidth === refWidth ? oWidth : refWidth);
const heightDiff = sHeight / (sHeight === refHeight ? oHeight : refHeight);
return widthDiff <= allowDiff || heightDiff <= allowDiff;
}
async sameAspectRatio(otherImage: ImageData) {
let thisRes = this.actualResolution;
let otherRes = otherImage.actualResolution;
@@ -207,12 +226,12 @@ class ImageData {
return {width: width as number, height: height as number};
}
async normalizeImagesForComparison(compareLibrary: ('pixel' | 'resemble'), imgToCompare: ImageData): Promise<[Sharp, Sharp, number, number]> {
async normalizeImagesForComparison(compareLibrary: ('pixel' | 'resemble'), imgToCompare: ImageData, usePreferredResolution = true): Promise<[Sharp, Sharp, number, number]> {
const sFunc = await getSharpAsync();
let refImage = this as ImageData;
let compareImage = imgToCompare;
if (this.preferredResolution !== undefined) {
if (usePreferredResolution && this.preferredResolution !== undefined) {
const matchingVariant = compareImage.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
if (matchingVariant !== undefined) {
compareImage = matchingVariant;

View File

@@ -1,5 +1,3 @@
import {ActivityType} from "./Reddit";
/**
* A duration and how to compare it against a value
*
@@ -150,7 +148,6 @@ export type RecordOutputOption = boolean | RecordOutputType | RecordOutputType[]
export type PostBehaviorType = 'next' | 'stop' | 'nextRun' | string;
export type onExistingFoundBehavior = 'replace' | 'skip' | 'ignore';
export type ActionTarget = 'self' | 'parent';
export type ArbitraryActionTarget = ActionTarget | string;
export type InclusiveActionTarget = ActionTarget | 'any';
export type DispatchSource = 'dispatch' | `dispatch:${string}`;
export type NonDispatchActivitySource = 'poll' | `poll:${PollOn}` | 'user' | `user:${string}`;
@@ -177,7 +174,6 @@ export type ActivitySource = NonDispatchActivitySource | DispatchSource;
export type ConfigFormat = 'json' | 'yaml';
export type ActionTypes =
'comment'
| 'submission'
| 'lock'
| 'remove'
| 'report'
@@ -286,84 +282,3 @@ export interface ImageHashCacheData {
original?: string
flipped?: string
}
// https://www.reddit.com/message/compose?to=/r/mealtimevideos&message=https://www.reddit.com/r/ContextModBot/comments/otz396/introduction_to_contextmodbot
export interface BaseTemplateData {
botLink: string
modmailLink?: string
manager?: string
check?: string
//[key: string]: any
}
export interface ActivityTemplateData {
kind: ActivityType
author: string
votes: number
age: string
permalink: string
id: string
subreddit: string
title: string
shortTitle: string
}
export interface ModdedActivityTemplateData {
reports: number
modReports: number
userReports: number
}
export interface SubmissionTemplateData extends ActivityTemplateData, Partial<ModdedActivityTemplateData> {
nsfw: boolean
spoiler: boolean
op: boolean
upvoteRatio: string
url: string
}
export interface CommentTemplateData extends ActivityTemplateData, Partial<ModdedActivityTemplateData> {
op: boolean
}
export interface SubredditTemplateData {
subredditBreakdownFormatted: string
subredditBreakdown?: {
totalFormatted: string
submissionFormatted: string
commentFormatted: string
}
}
export interface RuleResultTemplateData {
kind: string
triggered: boolean
result: string
[key: string]: any
}
export interface ActionResultTemplateData {
kind: string
success: boolean
result: string
[key: string]: any
}
export interface ActionResultsTemplateData {
actionSummary: string
actions: {
[key: string]: ActionResultTemplateData
}
}
export interface RuleResultsTemplateData {
ruleSummary: string
rules: {
[key: string]: RuleResultTemplateData
}
}
export interface GenericContentTemplateData extends BaseTemplateData, Partial<RuleResultsTemplateData>, Partial<ActionResultsTemplateData> {
item?: (SubmissionTemplateData | CommentTemplateData)
}

View File

@@ -245,11 +245,10 @@ export const authorCriteriaProperties = ['name', 'flairCssClass', 'flairText', '
* */
export interface AuthorCriteria {
/**
* A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the "u/" prefix
*
* A list of reddit usernames (case-insensitive) to match against. Do not include the "u/" prefix
*
* EX to match against /u/FoxxMD and /u/AnotherUser use ["FoxxMD","AnotherUser"]
* @examples ["FoxxMD","AnotherUser", "/.*Foxx.\/*i"]
* @examples ["FoxxMD","AnotherUser"]
* */
name?: string[],
/**

View File

@@ -88,18 +88,3 @@ export interface SubredditRemovalReason {
id: string,
title: string
}
export interface SubredditActivityAbsoluteBreakdown {
count: number
name: string
}
export interface SubredditActivityBreakdown extends SubredditActivityAbsoluteBreakdown {
percent: number
}
export interface SubredditActivityBreakdownByType {
total: SubredditActivityBreakdown[]
submission: SubredditActivityBreakdown[]
comment: SubredditActivityBreakdown[]
}

View File

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

View File

@@ -42,4 +42,4 @@ export const filterCriteriaDefault: FilterCriteriaDefaults = {
export const defaultDataDir = path.resolve(__dirname, '../..');
export const defaultConfigFilenames = ['config.json', 'config.yaml'];
export const VERSION = '0.12.2';
export const VERSION = '0.11.4';

View File

@@ -1060,16 +1060,6 @@ export interface SubredditOverrides {
* */
retention?: EventRetentionPolicyRange
}
/**
* The relative URL to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`
*
* This will override the default relative URL as well as any URL set at the bot-level
*
* @default "botconfig/contextbot"
* @examples ["botconfig/contextbot"]
* */
wikiConfig?: string
}
/**
@@ -1730,7 +1720,6 @@ export interface ActionProcessResult {
dryRun: boolean,
result?: string
touchedEntities?: (Submission | Comment | RedditUser | string)[]
data?: any
}
export interface EventActivity {

View File

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

View File

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

View File

@@ -7,10 +7,9 @@ import {Rule, RuleJSONConfig, RuleOptions} from "./index";
import Submission from "snoowrap/dist/objects/Submission";
import dayjs from "dayjs";
import {
asComment,
asSubmission,
FAIL,
formatNumber, getActivitySubredditName, historyFilterConfigToOptions, isComment, isSubmission,
formatNumber, getActivitySubredditName, historyFilterConfigToOptions, isSubmission,
parseSubredditName,
PASS,
percentFromString, removeUndefinedKeys, toStrongSubredditState, windowConfigToWindowCriteria
@@ -21,7 +20,6 @@ import {CompareValueOrPercent} from "../Common/Infrastructure/Atomic";
import {ActivityWindowConfig, ActivityWindowCriteria} from "../Common/Infrastructure/ActivityWindow";
import {ErrorWithCause} from "pony-cause";
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils";
export interface CommentThresholdCriteria extends ThresholdCriteria {
/**
@@ -208,11 +206,10 @@ export class HistoryRule extends Rule {
fOpTotal = filteredCounts.opTotal;
}
let asOp = false;
let commentTrigger = undefined;
if(comment !== undefined) {
const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(comment);
asOp = extra.toLowerCase().includes('op');
const asOp = extra.toLowerCase().includes('op');
if(isPercent) {
const per = value / 100;
if(asOp) {
@@ -267,8 +264,7 @@ export class HistoryRule extends Rule {
submissionTrigger,
commentTrigger,
totalTrigger,
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true),
subredditBreakdown: getSubredditBreakdownByActivityType(!asOp ? filteredActivities : filteredActivities.filter(x => asSubmission(x) || x.is_submitter))
triggered: (submissionTrigger === undefined || submissionTrigger === true) && (commentTrigger === undefined || commentTrigger === true) && (totalTrigger === undefined || totalTrigger === true)
});
}
@@ -324,7 +320,6 @@ export class HistoryRule extends Rule {
submissionTrigger,
commentTrigger,
totalTrigger,
subredditBreakdown,
} = results;
const data: any = {
@@ -343,7 +338,6 @@ export class HistoryRule extends Rule {
submissionTrigger,
commentTrigger,
totalTrigger,
subredditBreakdown
};
let thresholdSummary = [];

View File

@@ -43,7 +43,6 @@ import {
import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/ActivityWindow";
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
import {ImageHashCacheData} from "../Common/Infrastructure/Atomic";
import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils";
const parseLink = parseUsableLinkIdentifier();
@@ -509,7 +508,6 @@ export class RecentActivityRule extends Rule {
testValue,
karmaThreshold,
combinedKarma,
subredditBreakdown: getSubredditBreakdownByActivityType(activities)
}
};
}

View File

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

View File

@@ -46,9 +46,6 @@
{
"$ref": "#/definitions/ModNoteActionJson"
},
{
"$ref": "#/definitions/SubmissionActionJson"
},
{
"type": "string"
}
@@ -289,11 +286,10 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser",
"/.*Foxx./*i"
"AnotherUser"
],
"items": {
"type": "string"
@@ -708,20 +704,6 @@
"sticky": {
"description": "Stick the comment after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
}
},
"required": [
@@ -1665,13 +1647,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit",
"r/{{item.subreddit}}"
"r/aSubreddit"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -2310,170 +2292,6 @@
],
"type": "object"
},
"SubmissionActionJson": {
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"examples": [
"This is the content of a comment/report/usernote",
"this is **bold** markdown text",
"wiki:botconfig/acomment"
],
"type": "string"
},
"distinguish": {
"description": "Distinguish as Mod after creation?",
"type": "boolean"
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
"examples": [
false,
true
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"flairId": {
"description": "Flair template to apply to this Submission",
"type": "string"
},
"flairText": {
"description": "Flair text to apply to this Submission",
"type": "string"
},
"footer": {
"anyOf": [
{
"enum": [
false
],
"type": "boolean"
},
{
"type": "string"
}
],
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"submission"
],
"type": "string"
},
"lock": {
"description": "Lock the Submission after creation?",
"type": "boolean"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"examples": [
"myDescriptiveAction"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"nsfw": {
"type": "boolean"
},
"spoiler": {
"type": "boolean"
},
"sticky": {
"description": "Sticky the Submission after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
"type": "string"
},
"url": {
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
"type": "string"
}
},
"required": [
"kind",
"title"
],
"type": "object"
},
"SubmissionState": {
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
"examples": [

View File

@@ -665,11 +665,10 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser",
"/.*Foxx./*i"
"AnotherUser"
],
"items": {
"type": "string"
@@ -1377,20 +1376,6 @@
"sticky": {
"description": "Stick the comment after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
}
},
"required": [
@@ -1462,9 +1447,6 @@
{
"$ref": "#/definitions/ModNoteActionJson"
},
{
"$ref": "#/definitions/SubmissionActionJson"
},
{
"type": "string"
}
@@ -2181,6 +2163,69 @@
},
"type": "object"
},
"FilterCriteriaDefaults": {
"properties": {
"authorIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
},
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
}
]
},
"type": "array"
}
]
},
"authorIsBehavior": {
"enum": [
"merge",
"replace"
],
"type": "string"
},
"itemIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
},
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
}
]
},
"type": "array"
}
]
},
"itemIsBehavior": {
"description": "Determine how itemIs defaults behave when itemIs is present on the check\n\n* merge => adds defaults to check's itemIs\n* replace => check itemIs will replace defaults (no defaults used)",
"enum": [
"merge",
"replace"
],
"type": "string"
}
},
"type": "object"
},
"FilterCriteriaDefaultsJson": {
"properties": {
"authorIs": {
@@ -2251,6 +2296,62 @@
},
"type": "object"
},
"FilterOptions<AuthorCriteria>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptions<TypedActivityState>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptionsConfig<ActivityState>": {
"properties": {
"exclude": {
@@ -3336,13 +3437,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit",
"r/{{item.subreddit}}"
"r/aSubreddit"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -5244,7 +5345,7 @@
"type": "boolean"
},
"filterCriteriaDefaults": {
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
"$ref": "#/definitions/FilterCriteriaDefaults",
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
},
"itemIs": {
@@ -5560,170 +5661,6 @@
],
"type": "object"
},
"SubmissionActionJson": {
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"examples": [
"This is the content of a comment/report/usernote",
"this is **bold** markdown text",
"wiki:botconfig/acomment"
],
"type": "string"
},
"distinguish": {
"description": "Distinguish as Mod after creation?",
"type": "boolean"
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
"examples": [
false,
true
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"flairId": {
"description": "Flair template to apply to this Submission",
"type": "string"
},
"flairText": {
"description": "Flair text to apply to this Submission",
"type": "string"
},
"footer": {
"anyOf": [
{
"enum": [
false
],
"type": "boolean"
},
{
"type": "string"
}
],
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"submission"
],
"type": "string"
},
"lock": {
"description": "Lock the Submission after creation?",
"type": "boolean"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"examples": [
"myDescriptiveAction"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"nsfw": {
"type": "boolean"
},
"spoiler": {
"type": "boolean"
},
"sticky": {
"description": "Sticky the Submission after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
"type": "string"
},
"url": {
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
"type": "string"
}
},
"required": [
"kind",
"title"
],
"type": "object"
},
"SubmissionCheckConfigData": {
"properties": {
"actions": {
@@ -5787,9 +5724,6 @@
{
"$ref": "#/definitions/ModNoteActionJson"
},
{
"$ref": "#/definitions/SubmissionActionJson"
},
{
"type": "string"
}

View File

@@ -679,11 +679,10 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser",
"/.*Foxx./*i"
"AnotherUser"
],
"items": {
"type": "string"
@@ -1200,20 +1199,6 @@
"sticky": {
"description": "Stick the comment after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
}
},
"required": [
@@ -1285,9 +1270,6 @@
{
"$ref": "#/definitions/ModNoteActionJson"
},
{
"$ref": "#/definitions/SubmissionActionJson"
},
{
"type": "string"
}
@@ -3050,13 +3032,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit",
"r/{{item.subreddit}}"
"r/aSubreddit"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -5004,170 +4986,6 @@
],
"type": "object"
},
"SubmissionActionJson": {
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"examples": [
"This is the content of a comment/report/usernote",
"this is **bold** markdown text",
"wiki:botconfig/acomment"
],
"type": "string"
},
"distinguish": {
"description": "Distinguish as Mod after creation?",
"type": "boolean"
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
"examples": [
false,
true
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"flairId": {
"description": "Flair template to apply to this Submission",
"type": "string"
},
"flairText": {
"description": "Flair text to apply to this Submission",
"type": "string"
},
"footer": {
"anyOf": [
{
"enum": [
false
],
"type": "boolean"
},
{
"type": "string"
}
],
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"submission"
],
"type": "string"
},
"lock": {
"description": "Lock the Submission after creation?",
"type": "boolean"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"examples": [
"myDescriptiveAction"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"nsfw": {
"type": "boolean"
},
"spoiler": {
"type": "boolean"
},
"sticky": {
"description": "Sticky the Submission after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
"type": "string"
},
"url": {
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
"type": "string"
}
},
"required": [
"kind",
"title"
],
"type": "object"
},
"SubmissionCheckConfigData": {
"properties": {
"actions": {
@@ -5231,9 +5049,6 @@
{
"$ref": "#/definitions/ModNoteActionJson"
},
{
"$ref": "#/definitions/SubmissionActionJson"
},
{
"type": "string"
}

View File

@@ -133,11 +133,10 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser",
"/.*Foxx./*i"
"AnotherUser"
],
"items": {
"type": "string"
@@ -2031,14 +2030,6 @@
},
"name": {
"type": "string"
},
"wikiConfig": {
"default": "botconfig/contextbot",
"description": "The relative URL to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`\n\nThis will override the default relative URL as well as any URL set at the bot-level",
"examples": [
"botconfig/contextbot"
],
"type": "string"
}
},
"required": [

View File

@@ -594,11 +594,10 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser",
"/.*Foxx./*i"
"AnotherUser"
],
"items": {
"type": "string"

View File

@@ -562,11 +562,10 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser",
"/.*Foxx./*i"
"AnotherUser"
],
"items": {
"type": "string"

View File

@@ -676,11 +676,10 @@
"type": "array"
},
"name": {
"description": "A list of reddit usernames (case-insensitive) or regular expressions to match against. Do not include the \"u/\" prefix\n\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"description": "A list of reddit usernames (case-insensitive) to match against. Do not include the \"u/\" prefix\n\n EX to match against /u/FoxxMD and /u/AnotherUser use [\"FoxxMD\",\"AnotherUser\"]",
"examples": [
"FoxxMD",
"AnotherUser",
"/.*Foxx./*i"
"AnotherUser"
],
"items": {
"type": "string"
@@ -1197,20 +1196,6 @@
"sticky": {
"description": "Stick the comment after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
}
},
"required": [
@@ -1282,9 +1267,6 @@
{
"$ref": "#/definitions/ModNoteActionJson"
},
{
"$ref": "#/definitions/SubmissionActionJson"
},
{
"type": "string"
}
@@ -1962,10 +1944,13 @@
},
"type": "object"
},
"FilterCriteriaDefaultsJson": {
"FilterCriteriaDefaults": {
"properties": {
"authorIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<AuthorCriteria>"
},
{
"items": {
"anyOf": [
@@ -1974,19 +1959,12 @@
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "Determine how authorIs defaults behave when authorIs is present on the check\n\n* merge => merges defaults with check's authorIs\n* replace => check authorIs will replace defaults (no defaults used)"
]
},
"authorIsBehavior": {
"enum": [
@@ -1997,6 +1975,9 @@
},
"itemIs": {
"anyOf": [
{
"$ref": "#/definitions/FilterOptions<TypedActivityState>"
},
{
"items": {
"anyOf": [
@@ -2008,16 +1989,10 @@
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
]
},
@@ -2032,6 +2007,62 @@
},
"type": "object"
},
"FilterOptions<AuthorCriteria>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptions<TypedActivityState>": {
"properties": {
"exclude": {
"description": "Only runs if `include` is not present. Each Criteria is comprised of conditions that the filter (Author/Item) being checked must \"not\" pass. See excludeCondition for set behavior\n\nEX: `isMod: true, name: Automoderator` => Will pass if the Author IS NOT a mod and IS NOT named Automoderator",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
},
"excludeCondition": {
"default": "OR",
"description": "* OR => if ANY exclude condition \"does not\" pass then the exclude test passes\n* AND => if ALL exclude conditions \"do not\" pass then the exclude test passes\n\nDefaults to OR",
"enum": [
"AND",
"OR"
],
"type": "string"
},
"include": {
"description": "Will \"pass\" if any set of Criteria passes",
"items": {
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
"type": "array"
}
},
"type": "object"
},
"FilterOptionsConfig<ActivityState>": {
"properties": {
"exclude": {
@@ -3117,13 +3148,13 @@
"type": "string"
},
"to": {
"description": "Entity to send message to. It can be templated.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit\n\n**Tip:** To send a message to the subreddit of the Activity us `to: 'r/{{item.subreddit}}'`",
"description": "Entity to send message to.\n\nIf not present Message be will sent to the Author of the Activity being checked.\n\nValid formats:\n\n* `aUserName` -- send to /u/aUserName\n* `u/aUserName` -- send to /u/aUserName\n* `r/aSubreddit` -- sent to modmail of /r/aSubreddit\n\n**Note:** Reddit does not support sending a message AS a subreddit TO another subreddit",
"examples": [
"aUserName",
"u/aUserName",
"r/aSubreddit",
"r/{{item.subreddit}}"
"r/aSubreddit"
],
"pattern": "^\\s*(\\/[ru]\\/|[ru]\\/)*(\\w+)*\\s*$",
"type": "string"
}
},
@@ -4885,7 +4916,7 @@
"type": "boolean"
},
"filterCriteriaDefaults": {
"$ref": "#/definitions/FilterCriteriaDefaultsJson",
"$ref": "#/definitions/FilterCriteriaDefaults",
"description": "Set the default filter criteria for all checks. If this property is specified it will override any defaults passed from the bot's config\n\nDefault behavior is to exclude all mods and automoderator from checks"
},
"itemIs": {
@@ -5201,170 +5232,6 @@
],
"type": "object"
},
"SubmissionActionJson": {
"description": "Reply to the Activity. For a submission the reply will be a top-level comment.",
"properties": {
"authorIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/AuthorCriteria"
},
{
"$ref": "#/definitions/NamedCriteria<AuthorCriteria>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<AuthorCriteria>"
}
],
"description": "If present then these Author criteria are checked before running the Check. If criteria fails then the Check will fail."
},
"content": {
"description": "The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.\n\nIf value starts with `wiki:` then the proceeding value will be used to get a wiki page from the current subreddit\n\n * EX `wiki:botconfig/mybot` tries to get `https://reddit.com/r/currentSubreddit/wiki/botconfig/mybot`\n\nIf the value starts with `wiki:` and ends with `|someValue` then `someValue` will be used as the base subreddit for the wiki page\n\n* EX `wiki:replytemplates/test|ContextModBot` tries to get `https://reddit.com/r/ContextModBot/wiki/replytemplates/test`\n\nIf the value starts with `url:` then the value is fetched as an external url and expects raw text returned\n\n* EX `url:https://pastebin.com/raw/38qfL7mL` tries to get the text response of `https://pastebin.com/raw/38qfL7mL`\n\nIf none of the above is used the value is treated as the raw context\n\n * EX `this is **bold** markdown text` => \"this is **bold** markdown text\"\n\nAll Content is rendered using [mustache](https://github.com/janl/mustache.js/#templates) to enable [Action Templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nThe following properties are always available in the template (view individual Rules to see rule-specific template data):\n```\nitem.kind => The type of Activity that was checked (comment/submission)\nitem.author => The name of the Author of the Activity EX FoxxMD\nitem.permalink => A permalink URL to the Activity EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nitem.url => If the Activity is Link Sumbission then the external URL\nitem.title => If the Activity is a Submission then the title of that Submission\nrules => An object containing RuleResults of all the rules run for this check. See Action Templating for more details on naming\n```",
"examples": [
"This is the content of a comment/report/usernote",
"this is **bold** markdown text",
"wiki:botconfig/acomment"
],
"type": "string"
},
"distinguish": {
"description": "Distinguish as Mod after creation?",
"type": "boolean"
},
"dryRun": {
"default": false,
"description": "If `true` the Action will not make the API request to Reddit to perform its action.",
"examples": [
false,
true
],
"type": "boolean"
},
"enable": {
"default": true,
"description": "If set to `false` the Action will not be run",
"examples": [
true
],
"type": "boolean"
},
"flairId": {
"description": "Flair template to apply to this Submission",
"type": "string"
},
"flairText": {
"description": "Flair text to apply to this Submission",
"type": "string"
},
"footer": {
"anyOf": [
{
"enum": [
false
],
"type": "boolean"
},
{
"type": "string"
}
],
"description": "Customize the footer for Actions that send replies (Comment/Ban)\n\nIf `false` no footer is appended\n\nIf `string` the value is rendered as markdown or will use `wiki:` parser the same way `content` properties on Actions are rendered with [templating](https://github.com/FoxxMD/context-mod#action-templating).\n\nIf footer is `undefined` (not set) the default footer will be used:\n\n> *****\n> This action was performed by [a bot.] Mention a moderator or [send a modmail] if you any ideas, questions, or concerns about this action.\n\n*****\n\nThe following properties are available for [templating](https://github.com/FoxxMD/context-mod#action-templating):\n```\nsubName => name of subreddit Action was performed in (EX 'mealtimevideos')\npermaLink => The permalink for the Activity the Action was performed on EX https://reddit.com/r/yourSub/comments/o1h0i0/title_name/1v3b7x\nmodmaiLink => An encoded URL that will open a new message to your subreddit with the Action permalink appended to the body\nbotLink => A permalink to the FAQ for this bot.\n```\nIf you use your own footer or no footer **please link back to the bot FAQ** using the `{{botLink}}` property in your content :)"
},
"itemIs": {
"anyOf": [
{
"items": {
"anyOf": [
{
"$ref": "#/definitions/SubmissionState"
},
{
"$ref": "#/definitions/CommentState"
},
{
"$ref": "#/definitions/NamedCriteria<TypedActivityState>"
},
{
"type": "string"
}
]
},
"type": "array"
},
{
"$ref": "#/definitions/FilterOptionsJson<TypedActivityState>"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the check.\n\nIf any set of criteria passes the Check will be run. If the criteria fails then the Check will fail.\n\n* @examples [[{\"over_18\": true, \"removed': false}]]"
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"submission"
],
"type": "string"
},
"lock": {
"description": "Lock the Submission after creation?",
"type": "boolean"
},
"name": {
"description": "An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"examples": [
"myDescriptiveAction"
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"nsfw": {
"type": "boolean"
},
"spoiler": {
"type": "boolean"
},
"sticky": {
"description": "Sticky the Submission after creation?",
"type": "boolean"
},
"targets": {
"anyOf": [
{
"items": {
"type": "string"
},
"type": "array"
},
{
"type": "string"
}
],
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos"
},
"title": {
"description": "The title of this Submission.\n\nTemplated the same as **content**",
"type": "string"
},
"url": {
"description": "If Submission should be a Link, the URL to use\n\nTemplated the same as **content**\n\nPROTIP: To make a Link Submission pointing to the Activity being processed use `{{item.permalink}}` as the URL value",
"type": "string"
}
},
"required": [
"kind",
"title"
],
"type": "object"
},
"SubmissionCheckConfigData": {
"properties": {
"actions": {
@@ -5428,9 +5295,6 @@
{
"$ref": "#/definitions/ModNoteActionJson"
},
{
"$ref": "#/definitions/SubmissionActionJson"
},
{
"type": "string"
}

View File

@@ -3,7 +3,7 @@ import objectHash from 'object-hash';
import {
activityIsDeleted, activityIsFiltered,
activityIsRemoved,
AuthorTypedActivitiesOptions, BOT_LINK, TemplateContext,
AuthorTypedActivitiesOptions, BOT_LINK,
getAuthorHistoryAPIOptions, renderContent
} from "../Utils/SnoowrapUtils";
import {map as mapAsync} from 'async';
@@ -161,7 +161,6 @@ import {IncludesData} from "../Common/Infrastructure/Includes";
import {parseFromJsonOrYamlToObject} from "../Common/Config/ConfigUtil";
import ConfigParseError from "../Utils/ConfigParseError";
import {ActivityReport} from "../Common/Entities/ActivityReport";
import {ActionResultEntity} from "../Common/Entities/ActionResultEntity";
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.';
@@ -995,7 +994,7 @@ export class SubredditResources {
hash = `sub-${item.name}`;
if (tryToFetch && item instanceof Submission) {
// @ts-ignore
const itemToCache = await item.refresh();
const itemToCache = await item.fetch();
await this.cache.set(hash, itemToCache, {ttl: this.submissionTTL});
return itemToCache;
} else {
@@ -1007,7 +1006,7 @@ export class SubredditResources {
hash = `comm-${item.name}`;
if (tryToFetch && item instanceof Comment) {
// @ts-ignore
const itemToCache = await item.refresh();
const itemToCache = await item.fetch();
await this.cache.set(hash, itemToCache, {ttl: this.commentTTL});
return itemToCache;
} else {
@@ -1017,12 +1016,8 @@ export class SubredditResources {
}
}
return item;
} catch (e: any) {
if(e.message !== undefined && e.message.includes('Cannot read properties of undefined (reading \'constructor\')')) {
throw new ErrorWithCause('Error occurred while trying to add Activity to cache (Comment likely does not exist)', {cause: e});
} else {
throw new ErrorWithCause('Error occurred while trying to add Activity to cache', {cause: e});
}
} catch (e) {
throw new ErrorWithCause('Error occurred while trying to add Activity to cache', {cause: e});
}
}
@@ -1107,9 +1102,8 @@ export class SubredditResources {
return subreddit as Subreddit;
}
} catch (err: any) {
const cmError = new CMError('Error while trying to fetch a cached subreddit', {cause: err, logged: true});
this.logger.error(cmError);
throw cmError;
this.logger.error('Error while trying to fetch a cached subreddit', err);
throw err.logged;
}
}
@@ -1770,24 +1764,9 @@ export class SubredditResources {
/**
* Convenience method for using getContent and SnoowrapUtils@renderContent in one method
* */
async renderContent(contentStr: string, activity: SnoowrapActivity, ruleResults: RuleResultEntity[] = [], actionResults: ActionResultEntity[] = [], templateData: TemplateContext = {}) {
async renderContent(contentStr: string, data: SnoowrapActivity, ruleResults: RuleResultEntity[] = [], usernotes?: UserNotes) {
const content = await this.getContent(contentStr);
const {usernotes = this.userNotes, ...restData} = templateData;
return await renderContent(content, {
...restData,
activity,
usernotes,
ruleResults,
actionResults,
});
}
async renderFooter(item: Submission | Comment, footer: false | string | undefined = this.footer) {
if (footer === false) {
return '';
}
return this.renderContent(footer, item);
return await renderContent(content, data, ruleResults, usernotes ?? this.userNotes);
}
async getConfigFragment<T>(includesData: IncludesData, validateFunc?: ConfigFragmentValidationFunc): Promise<T> {
@@ -2780,7 +2759,7 @@ export class SubredditResources {
const authPass = () => {
for (const n of nameVal) {
if (testMaybeStringRegex(n, authorName)[0]) {
if (n.toLowerCase() === authorName.toLowerCase()) {
return true;
}
}
@@ -3361,6 +3340,19 @@ export class SubredditResources {
this.logger.debug(`Cached check result '${result.check.name}' for User ${userName} on Submission ${item.link_id} for ${ttl} seconds (Hash ${hash})`);
}
async generateFooter(item: Submission | Comment, actionFooter?: false | string) {
let footer = actionFooter !== undefined ? actionFooter : this.footer;
if (footer === false) {
return '';
}
const subName = await item.subreddit.display_name;
const permaLink = `https://reddit.com${await item.permalink}`
const modmailLink = `https://www.reddit.com/message/compose?to=%2Fr%2F${subName}&message=${encodeURIComponent(permaLink)}`
const footerRawContent = await this.getContent(footer, item.subreddit);
return he.decode(Mustache.render(footerRawContent, {subName, permaLink, modmailLink, botLink: BOT_LINK}));
}
async getImageHash(img: ImageData): Promise<Required<ImageHashCacheData>|undefined> {
if(img.hashResult !== undefined && img.hashResultFlipped !== undefined) {

View File

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

View File

@@ -1029,7 +1029,6 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
...req.instancesViewData,
bots: resp.bots,
now: dayjs().add(1, 'minute').format('YYYY-MM-DDTHH:mm'),
defaultExpire: dayjs().add(1, 'day').format('YYYY-MM-DDTHH:mm'),
botId: (req.instance as CMInstance).getName(),
isOperator: isOp,
system: isOp ? {
@@ -1463,6 +1462,27 @@ const webClient = async (options: OperatorConfigWithFileContext) => {
}
emitter.on('log', botWebLogListener);
socketListeners.set(socket.id, [...(socketListeners.get(socket.id) || []), botWebLogListener]);
// only setup streams if the user can actually access them (not just a web operator)
if(session.authBotId !== undefined) {
// streaming stats from client
const newStreams: (AbortController | NodeJS.Timeout)[] = [];
const interval = setInterval(async () => {
try {
const resp = await got.get(`${bot.normalUrl}/stats`, {
headers: {
'Authorization': `Bearer ${createToken(bot, user)}`,
}
}).json() as object;
io.to(session.id).emit('opStats', resp);
} catch (err: any) {
bot.logger.error(new ErrorWithCause('Could not retrieve stats', {cause: err}));
clearInterval(interval);
}
}, 5000);
newStreams.push(interval);
sockStreams.set(socket.id, newStreams);
}
}
}
}

View File

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

View File

@@ -13,7 +13,7 @@ import http from "http";
import {heartbeat} from "./routes/authenticated/applicationRoutes";
import logs from "./routes/authenticated/user/logs";
import status from './routes/authenticated/user/status';
import liveStats, {opStatResponse} from './routes/authenticated/user/liveStats';
import liveStats from './routes/authenticated/user/liveStats';
import {
actionedEventsRoute,
actionRoute, addGuestModRoute,
@@ -161,8 +161,41 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
server.getAsync('/logs', ...logs());
server.getAsync('/stats', ...opStatResponse());
server.getAsync('/stats', [authUserCheck(), botRoute(false)], async (req: Request, res: Response) => {
let bots: Bot[] = [];
if(req.serverBot !== undefined) {
bots = [req.serverBot];
} else if(req.user !== undefined) {
bots = req.user.accessibleBots(req.botApp.bots);
}
const resp = [];
let index = 1;
for(const b of bots) {
resp.push({name: b.botName ?? `Bot ${index}`, data: {
status: b.running ? 'RUNNING' : 'NOT RUNNING',
indicator: b.running ? 'green' : 'red',
running: b.running,
startedAt: b.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
error: b.error,
subreddits: req.user?.accessibleSubreddits(b).map((manager: Manager) => {
let indicator;
if (manager.managerState.state === RUNNING && manager.queueState.state === RUNNING && manager.eventsState.state === RUNNING) {
indicator = 'green';
} else if (manager.managerState.state === STOPPED && manager.queueState.state === STOPPED && manager.eventsState.state === STOPPED) {
indicator = 'red';
} else {
indicator = 'yellow';
}
return {
name: manager.displayLabel,
indicator,
};
}),
}});
index++;
}
return res.json(resp);
});
const passLogs = async (req: Request, res: Response, next: Function) => {
// @ts-ignore
req.sysLogs = sysLogs;

View File

@@ -190,8 +190,3 @@ li > ul {
.introjs-tooltip-title,.introjs-tooltiptext {
color: black;
}
.guestAdd {
border-top: 1px solid white;
padding-top: 0.5em;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -21,7 +21,6 @@
<div class="container mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center flex-grow pr-4">
<a href="/"><img src="/public/logo.png" style="max-height:40px; padding-right: 0.75rem;"/></a>
<% if(locals.title !== undefined) { %>
<a href="/events?instance=<%= instance %>&bot=<%= bot %><%= subreddit !== undefined ? `&subreddit=${subreddit}` : '' %>"><%= title %></a>
<% } %>

View File

@@ -20,7 +20,7 @@
statusEl.innerHTML = '<span class="iconify-inline green" data-icon="ep:circle-check-filled"></span>';
break;
default:
statusEl.innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
dstatusEl.innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
break;
}
// data.page.updated_at

View File

@@ -2,7 +2,6 @@
<div class="container mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center flex-grow pr-4">
<a href="/"><img src="/public/logo.png" style="max-height:40px;"/></a>
<% if(locals.instances !== undefined) { %>
<ul class="inline-flex flex-wrap">
<% instances.forEach(function (data) { %>

View File

@@ -2,7 +2,6 @@
<div class="container mx-auto">
<div class="flex items-center justify-between">
<div class="flex items-center flex-grow pr-4">
<a href="/"><img src="/public/logo.png" style="max-height:40px; padding-right: 0.75rem;"/></a>
<% if(locals.title !== undefined) { %>
<%= title %>
<% } %>

View File

@@ -288,25 +288,10 @@
style="width:200px;"
class="guestAddName border-gray-50 placeholder-gray-500 rounded mr-1 p-1 text-black"
placeholder="userName"/>
<div class="mt-2">
<span class="has-tooltip">
<span style="margin-top:55px" class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black space-y-3 p-2 text-left'>
When should Guest Access expire for this user?
</span>
<span>
Expires At<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">
<use xlink:href="public/questionsymbol.svg#q" />
</svg>
</span>
</span>
<input type="datetime-local"
class="guestAddTime border-gray-50 placeholder-gray-500 mr-2 rounded text-black"
value="<%= defaultExpire %>"
class="guestAddTime border-gray-50 placeholder-gray-500 mt-2 mr-2 rounded text-black"
value="<%= now %>"
min="<%= now %>"/>
</div>
</div>
<a href="" class="addGuest">Add</a>
@@ -1110,44 +1095,6 @@
const delayedItemsMap = new Map();
let lastSeenIdentifier = null;
const subIndicators = ['red', 'green', 'yellow'];
function updateOpStats(resp, responseType) {
for (const b of resp) {
const {
name,
data: {
running,
indicator,
subreddits = [],
} = {},
} = b;
const botTab = document.querySelector(`[data-bot="${name}"] .botTabStatus`);
if (botTab !== null) {
if (running !== undefined) {
const currentStatusClass = `bg-${running ? 'green' : 'red'}-400`;
const oppositeStatusClass = `bg-${running ? 'red' : 'green'}-400`;
if (!botTab.classList.contains(currentStatusClass)) {
botTab.classList.remove(oppositeStatusClass);
botTab.classList.add(currentStatusClass);
}
}
}
for (const subData of subreddits) {
const subredditTab = document.querySelector(`[data-bot="${name}"] [data-subreddit="${subData.name}"] .subredditTabStatus`);
if (subredditTab !== null) {
const currentSubIndicatorClass = `bg-${subData.indicator}-400`;
const nonSubIndicatorClasses = subIndicators.filter(x => x !== subData.indicator).map(x => `bg-${x}-400`);
if (!subredditTab.classList.contains(currentSubIndicatorClass)) {
for (const nonIndicator of nonSubIndicatorClasses) {
subredditTab.classList.remove(nonIndicator);
}
subredditTab.classList.add(currentSubIndicatorClass);
}
}
}
}
}
function updateLiveStats(resp, sub, bot, responseType) {
let el;
@@ -1413,32 +1360,30 @@
const now = dayjs();
if(el !== null) {
el.innerHTML = '';
if(data.length === 0) {
el.innerHTML = '';
if(data.length === 0) {
const node = document.createElement("LI");
node.classList.add('smallLi');
node.appendChild(document.createTextNode('None'));
el.appendChild(node);
} else {
for(const g of data) {
const node = document.createElement("LI");
node.classList.add('smallLi');
node.appendChild(document.createTextNode('None'));
el.appendChild(node);
} else {
for(const g of data) {
const node = document.createElement("LI");
node.classList.add('smallLi');
let relTime = g.expiresAt === undefined ? 'Never' : dayjs.duration(dayjs(g.expiresAt).diff(now)).humanize();
let guestText = g.name;
if(isAll) {
guestText += ` (${g.subreddits.length} Subs, at least ${relTime})`;
} else {
guestText += ` (${relTime})`;
}
node.appendChild(document.createTextNode(guestText));
node.insertAdjacentHTML('beforeend', `<a href="" class="remove ml-1" data-name="${g.name}"><span class="cancellable iconify-inline" data-icon="icons8:cancel"></span></a>`);
node.addEventListener('click', e => {
e.preventDefault();
removeGuestMod(bot, sub, g.name);
});
el.appendChild(node);
let relTime = g.expiresAt === undefined ? 'Never' : dayjs.duration(dayjs(g.expiresAt).diff(now)).humanize();
let guestText = g.name;
if(isAll) {
guestText += ` (${g.subreddits.length} Subs, at least ${relTime})`;
} else {
guestText += ` (${relTime})`;
}
node.appendChild(document.createTextNode(guestText));
node.insertAdjacentHTML('beforeend', `<a href="" class="remove ml-1" data-name="${g.name}"><span class="cancellable iconify-inline" data-icon="icons8:cancel"></span></a>`);
node.addEventListener('click', e => {
e.preventDefault();
removeGuestMod(bot, sub, g.name);
});
el.appendChild(node);
}
}
}
@@ -1465,23 +1410,6 @@
});
});
function getOpStats(responseType = 'full') {
console.debug(`Getting op live stats for <%= instanceId %>`)
return fetch(`/api/stats?instance=<%= instanceId %>&type=${responseType}`)
.then(response => {
if(response.status === 304) {
return Promise.resolve(false);
}
return response.json();
})
.then(resp => {
if(resp === false) {
return;
}
updateOpStats(resp, responseType);
});
}
function getLiveStats(bot, sub, responseType = 'full') {
console.debug(`Getting live stats for ${bot} ${sub}`)
return fetch(`/api/liveStats?instance=<%= instanceId %>&bot=${bot}&subreddit=${sub}&type=${responseType}`)
@@ -1587,19 +1515,6 @@
onVisible(el, () => onSubVisible(bot, sub));
});
//window.init = true;
let opTimeoutId = null;
let opTimeout = () => {
getOpStats('full').then(() => {
opTimeoutId = setInterval(() => {
getOpStats('delta').catch((err) => {
console.error(err);
clearInterval(opTimeoutId);
})
}, 10000);
});
}
let backgroundTimeout = null;
document.addEventListener("visibilitychange", (e) => {
@@ -1616,9 +1531,6 @@
controller.abort();
}
backgroundTimeout = null;
clearInterval(opTimeoutId);
opTimeoutId = null;
window.init = true;
}, 15000);
} else {
// cancel real-time data timeout because page is visible again
@@ -1635,15 +1547,10 @@
recentlySeen.delete(lastSeenIdentifier);
onSubVisible(bot, sub);
}
if(opTimeoutId === null) {
opTimeout();
}
}
}
});
opTimeout();
var searchParams = new URLSearchParams(window.location.search);
const shownSub = searchParams.get('sub') || 'All'
let shownBot = searchParams.get('bot');
@@ -1712,6 +1619,43 @@
socket.on("connect", () => {
document.body.classList.add('connected')
const shownSub = searchParams.get('sub') || 'All'
let shownBot = searchParams.get('bot');
window.socket.emit('viewing', {bot: shownBot, subreddit: shownSub});
// TODO web logging
// socket.on('log')
const subIndicators = ['red', 'green', 'yellow'];
socket.on('opStats', (resp) => {
for(const b of resp) {
const {name, data} = b;
const botTab = document.querySelector(`[data-bot="${name}"] .botTabStatus`);
if(botTab !== null) {
const currentStatusClass = `bg-${data.running ? 'green' : 'red'}-400`;
const oppositeStatusClass = `bg-${data.running ? 'red' : 'green'}-400`;
if(!botTab.classList.contains(currentStatusClass)) {
botTab.classList.remove(oppositeStatusClass);
botTab.classList.add(currentStatusClass);
}
}
for (const subData of data.subreddits) {
const subredditTab = document.querySelector(`[data-bot="${name}"] [data-subreddit="${subData.name}"] .subredditTabStatus`);
if(subredditTab !== null) {
const currentSubIndicatorClass = `bg-${subData.indicator}-400`;
const nonSubIndicatorClasses = subIndicators.filter(x => x !== subData.indicator).map(x => `bg-${x}-400`);
if(!subredditTab.classList.contains(currentSubIndicatorClass)) {
for(const nonIndicator of nonSubIndicatorClasses) {
subredditTab.classList.remove(nonIndicator);
}
subredditTab.classList.add(currentSubIndicatorClass);
}
}
}
}
});
});
socket.on('disconnect', () => {

View File

@@ -117,7 +117,6 @@ import {
import {RunnableBaseJson} from "./Common/Infrastructure/Runnable";
import Snoowrap from "snoowrap";
import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator';
import {ActionResultEntity} from "./Common/Entities/ActionResultEntity";
//import {ResembleSingleCallbackComparisonResult} from "resemblejs";
@@ -1935,28 +1934,21 @@ export function findLastIndex<T>(array: Array<T>, predicate: (value: T, index: n
return -1;
}
export const parseResultsToMarkdownSummary = (ruleResults: (RuleResultEntity | ActionResultEntity)[]): string => {
export const parseRuleResultsToMarkdownSummary = (ruleResults: RuleResultEntity[]): string => {
const results = ruleResults.map((y) => {
let name = y.premise.name;
const kind = y.premise.kind.name;
if(name === undefined) {
name = kind;
}
let runIndicator = null;
if(y instanceof RuleResultEntity) {
runIndicator = y.triggered;
} else {
runIndicator = y.success;
}
const {result, ...restY} = y;
const {triggered, result, ...restY} = y;
let t = triggeredIndicator(false);
if(runIndicator === null) {
if(triggered === null) {
t = 'Skipped';
} else if(runIndicator === true) {
} else if(triggered === true) {
t = triggeredIndicator(true);
}
return `* ${name} - ${t}${result !== undefined ? ` - ${result}` : ''}`;
return `* ${name} - ${t} - ${result || '-'}`;
});
return results.join('\r\n');
}

View File

@@ -103,8 +103,8 @@ describe('Hash Comparisons', function () {
});
await compareImg.hash(32);
const distanceNormal = leven(original.hashResult as string, compareImg.hashResult as string);
const diffNormal = (distanceNormal/(original.hashResult as string).length)*100;
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
const diffNormal = (distanceNormal/original.hashResult.length)*100;
assert.equal(diffNormal, 0);
});
@@ -116,8 +116,8 @@ describe('Hash Comparisons', function () {
});
await compareImg.hash(32);
const distanceNormal = leven(original.hashResult as string, compareImg.hashResult as string);
const diffNormal = (distanceNormal/(original.hashResult as string).length)*100;
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
const diffNormal = (distanceNormal/original.hashResult.length)*100;
assert.isAtMost(diffNormal, 4);
});
@@ -129,8 +129,8 @@ describe('Hash Comparisons', function () {
});
await compareImg.hash(32);
const distanceNormal = leven(original.hashResult as string, compareImg.hashResult as string);
const diffNormal = (distanceNormal/(original.hashResult as string).length)*100;
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
const diffNormal = (distanceNormal/original.hashResult.length)*100;
assert.equal(diffNormal, 0);
});
@@ -142,13 +142,13 @@ describe('Hash Comparisons', function () {
});
await flipped.hash(32);
const distanceNormal = leven(original.hashResult as string, flipped.hashResult as string);
const diffNormal = (distanceNormal/(original.hashResult as string).length)*100;
const distanceNormal = leven(original.hashResult, flipped.hashResult);
const diffNormal = (distanceNormal/original.hashResult.length)*100;
assert.isAtLeast(diffNormal, 50);
const distanceFlipped = leven(original.hashResult as string, flipped.hashResultFlipped as string);
const diffFlipped = (distanceFlipped/(original.hashResult as string).length)*100;
const distanceFlipped = leven(original.hashResult, flipped.hashResultFlipped);
const diffFlipped = (distanceFlipped/original.hashResult.length)*100;
assert.isAtMost(diffFlipped, 4);
});
@@ -160,8 +160,8 @@ describe('Hash Comparisons', function () {
});
await compareImg.hash(32);
const distanceNormal = leven(original.hashResult as string, compareImg.hashResult as string);
const diffNormal = (distanceNormal/(original.hashResult as string).length)*100;
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
const diffNormal = (distanceNormal/original.hashResult.length)*100;
assert.isAtMost(diffNormal, 10);
});
@@ -173,8 +173,8 @@ describe('Hash Comparisons', function () {
});
await compareImg.hash(32);
const distanceNormal = leven(original.hashResult as string, compareImg.hashResult as string);
const diffNormal = (distanceNormal/(original.hashResult as string).length)*100;
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
const diffNormal = (distanceNormal/original.hashResult.length)*100;
assert.isAtLeast(diffNormal, 50);
});