mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 16:08:02 -05:00
Compare commits
1 Commits
opencv
...
imageCompa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2028843714 |
12
Dockerfile
12
Dockerfile
@@ -81,9 +81,8 @@ RUN apk add --no-cache \
|
|||||||
#
|
#
|
||||||
|
|
||||||
# vips required to run sharp library for image comparison
|
# vips required to run sharp library for image comparison
|
||||||
# opencv required for other image processing
|
|
||||||
RUN echo "http://dl-4.alpinelinux.org/alpine/v3.14/community" >> /etc/apk/repositories \
|
RUN echo "http://dl-4.alpinelinux.org/alpine/v3.14/community" >> /etc/apk/repositories \
|
||||||
&& apk --no-cache add vips opencv opencv-dev
|
&& apk --no-cache add vips
|
||||||
|
|
||||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||||
|
|
||||||
@@ -116,15 +115,6 @@ RUN npm install --production \
|
|||||||
&& rm -rf node_modules/ts-node \
|
&& rm -rf node_modules/ts-node \
|
||||||
&& rm -rf node_modules/typescript
|
&& rm -rf node_modules/typescript
|
||||||
|
|
||||||
# build bindings for opencv
|
|
||||||
RUN apk add --no-cache --virtual .build-deps \
|
|
||||||
make \
|
|
||||||
g++ \
|
|
||||||
gcc \
|
|
||||||
libgcc \
|
|
||||||
&& npm run cv-install-docker-prebuild \
|
|
||||||
&& apk del .build-deps
|
|
||||||
|
|
||||||
ENV NPM_CONFIG_LOGLEVEL debug
|
ENV NPM_CONFIG_LOGLEVEL debug
|
||||||
|
|
||||||
# can set database to use more performant better-sqlite3 since we control everything
|
# can set database to use more performant better-sqlite3 since we control everything
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ They are responsible for configuring the software at a high-level and managing a
|
|||||||
|
|
||||||
# Overview
|
# 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.
|
* **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.
|
* **Client** -- Responsible for serving the **web interface** and handling the bot oauth authentication flow between operators and subreddits/bots.
|
||||||
|
|||||||
@@ -136,8 +136,6 @@ You will need have this information available:
|
|||||||
|
|
||||||
See the [**example minimum configuration** below.](#minimum-config)
|
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
|
# 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:
|
Configured using the `bots` top-level property. Bot configuration can override and specify many more options than are available at the operator-level. Many of these can also set the defaults for each subreddit the bot runs:
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ This getting started guide is for **Operators** -- that is, someone who wants to
|
|||||||
|
|
||||||
* [Installation](#installation)
|
* [Installation](#installation)
|
||||||
* [Create a Reddit Client](#create-a-reddit-client)
|
* [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)
|
* [Add a Bot to CM](#add-a-bot-to-cm)
|
||||||
* [Access The Dashboard](#access-the-dashboard)
|
* [Access The Dashboard](#access-the-dashboard)
|
||||||
* [What's Next?](#whats-next)
|
* [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)
|
[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))
|
## Local Installation
|
||||||
* Client Secret (from [Create a Reddit Client](#create-a-reddit-client))
|
|
||||||
* Operator -- this is the username of your main Reddit account.
|
|
||||||
|
|
||||||
**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
|
# 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.
|
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.
|
||||||
|
|
||||||
Follow the directions here and **create an Authorization Invite** at the bottom of the page.
|
|
||||||
|
|
||||||
Next, login to Reddit with the account you will be using as the Bot and then visit the **Authorization Invite** link you created. Follow the steps there to finish adding the Bot to your CM instance.
|
|
||||||
|
|
||||||
# Access The Dashboard
|
# 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)
|
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)
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ PROTIP: Using a container management tool like [Portainer.io CE](https://www.por
|
|||||||
An example of starting the container using the [minimum configuration](/docs/operator/configuration.md#minimum-config):
|
An example of starting the container using the [minimum configuration](/docs/operator/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`
|
* 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`
|
* Expose the web interface using the container port `8085`
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -60,65 +59,6 @@ An example of running CM using the [minimum configuration](/docs/operator/config
|
|||||||
node src/index.js run
|
node src/index.js run
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependencies
|
|
||||||
|
|
||||||
Note: All below dependencies are automatically included in the [Docker](#docker-recommended) image.
|
|
||||||
|
|
||||||
#### Sharp
|
|
||||||
|
|
||||||
For basic [Image Comparisons](/docs/imageComparison.md) and image data CM uses [sharp](https://sharp.pixelplumbing.com/) which depends on [libvips](https://www.libvips.org/)
|
|
||||||
|
|
||||||
Binaries for Sharp and libvips ship with the `sharp` npm package for all major operating systems and should require no additional steps to use -- installing CM with the above script should be sufficient.
|
|
||||||
|
|
||||||
See more about Sharp dependencies in the [image comparison prerequisites.](/docs/imageComparison.md#prerequisites)
|
|
||||||
|
|
||||||
|
|
||||||
#### OpenCV
|
|
||||||
|
|
||||||
For advanced image comparison CM uses [OpenCV.](https://opencv.org/) OpenCV is an **optional** dependency that is only utilized if CM is configured to run these advanced image operations so if you are NOT doing any image-related operations you can safely ignore this section/dependency.
|
|
||||||
|
|
||||||
**NOTE:** Depending on the image being compared (resolution) and operations being performed this can be a **CPU heavy resource.** TODO: Add rules that are cpu heavy...
|
|
||||||
|
|
||||||
##### Installation
|
|
||||||
|
|
||||||
Installation is not an automatic process. The below instructions are a summary of "easy" paths for installation but are not exhaustive. DO reference the detailed instructions (including additional details for windows installs) at [opencv4nodejs How to Install](https://github.com/UrielCh/opencv4nodejs#how-to-install).
|
|
||||||
|
|
||||||
###### Build From Source
|
|
||||||
|
|
||||||
This may take **some time** since openCV will be built from scratch.
|
|
||||||
|
|
||||||
On windows you must first install build tools: `npm install --global windows-build-tools`
|
|
||||||
|
|
||||||
Otherwise, run one of the following commands from the CM project directory:
|
|
||||||
|
|
||||||
* For CUDA (Nvidia GPU acceleration): `npm run cv-autoinstall-cuda`
|
|
||||||
* Normal: `npm run cv-autoinstall`
|
|
||||||
|
|
||||||
###### Build from Prebuilt
|
|
||||||
|
|
||||||
In this use-case you already have openCV built OR are using a distro prebuilt package. This method is much faster than building from source as only bindings need to be built.
|
|
||||||
|
|
||||||
[More information on prebuild installation](https://github.com/UrielCh/opencv4nodejs#installing-opencv-manually)
|
|
||||||
|
|
||||||
Prerequisites:
|
|
||||||
|
|
||||||
* Windows `choco install OpenCV -y -version 4.1.0`
|
|
||||||
* MacOS `brew install opencv@4; brew link --force opencv@4`
|
|
||||||
* Linux -- varies, check your package manager for `opencv` and `opencv-dev`
|
|
||||||
|
|
||||||
A script for building on **Ubuntu** is already included in CM:
|
|
||||||
|
|
||||||
* `sudo apt install opencv-dev`
|
|
||||||
* `npm run cv-install-ubuntu-prebuild`
|
|
||||||
|
|
||||||
Otherwise, you will need to modify `scripts` in CM's `package.json`, use the script `cv-install-ubuntu-prebuild` as an example. Your command must include:
|
|
||||||
|
|
||||||
* `--incDir [path/to/opencv/dev-files]` (on linux, usually `/usr/include/opencv4/`)
|
|
||||||
* `--libDir [path/to/opencv/shared-files]` (on linux usually `/lib/x86_64-linux-gnu/` or `/usr/lib/`)
|
|
||||||
* `--binDir=[path/to/openv/binaries]` (on linux usually `/usr/bin/`)
|
|
||||||
|
|
||||||
After you have modified/added a script for your operating system run it with `npm run yourScriptName`
|
|
||||||
|
|
||||||
## [Heroku Quick Deploy](https://heroku.com/about)
|
## [Heroku Quick Deploy](https://heroku.com/about)
|
||||||
|
|
||||||
**NOTE:** This is still experimental and requires more testing.
|
**NOTE:** This is still experimental and requires more testing.
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
|
|||||||
* [List of Actions](#list-of-actions)
|
* [List of Actions](#list-of-actions)
|
||||||
* [Approve](#approve)
|
* [Approve](#approve)
|
||||||
* [Ban](#ban)
|
* [Ban](#ban)
|
||||||
* [Submission](#submission)
|
|
||||||
* [Comment](#comment)
|
* [Comment](#comment)
|
||||||
* [Contributor (Add/Remove)](#contributor)
|
* [Contributor (Add/Remove)](#contributor)
|
||||||
* [Dispatch/Delay](#dispatch)
|
* [Dispatch/Delay](#dispatch)
|
||||||
@@ -495,30 +494,11 @@ actions:
|
|||||||
|
|
||||||
### Comment
|
### 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 Submission the comment is a top-level reply
|
||||||
* If the Activity is a Comment the comment is a child 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
|
```yaml
|
||||||
actions:
|
actions:
|
||||||
- kind: comment
|
- kind: comment
|
||||||
@@ -526,71 +506,7 @@ actions:
|
|||||||
distinguish: boolean # distinguish as a mod
|
distinguish: boolean # distinguish as a mod
|
||||||
sticky: boolean # sticky comment
|
sticky: boolean # sticky comment
|
||||||
lock: boolean # lock the comment after creation
|
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
|
### Contributor
|
||||||
|
|||||||
1944
package-lock.json
generated
1944
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,11 +21,7 @@
|
|||||||
"circular-graph": "madge --image graph.svg --circular --extensions ts src/index.ts",
|
"circular-graph": "madge --image graph.svg --circular --extensions ts src/index.ts",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
|
"typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js",
|
||||||
"initMigration": "npm run typeorm -- migration:generate -t 1642180264563 -d ormconfig.js \"src/Common/Migrations/Database/init\"",
|
"initMigration": "npm run typeorm -- migration:generate -t 1642180264563 -d ormconfig.js \"src/Common/Migrations/Database/init\""
|
||||||
"cv-install-ubuntu-prebuild": "build-opencv --incDir /usr/include/opencv4/ --libDir /lib/x86_64-linux-gnu/ --binDir=/usr/bin/ --nobuild rebuild",
|
|
||||||
"cv-install-docker-prebuild": "build-opencv --incDir /usr/include/opencv4/ --libDir /usr/lib/ --binDir=/usr/bin/ --nobuild rebuild",
|
|
||||||
"cv-autoinstall-cuda": "build-opencv --version 4.5.5 --flags=\"-DWITH_CUDA=ON -DWITH_CUDNN=ON -DOPENCV_DNN_CUDA=ON -DCUDA_FAST_MATH=ON\" build",
|
|
||||||
"cv-autoinstall": "build-opencv build"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16"
|
"node": ">=16"
|
||||||
@@ -46,7 +42,6 @@
|
|||||||
"@nlpjs/language": "^4.22.7",
|
"@nlpjs/language": "^4.22.7",
|
||||||
"@nlpjs/nlp": "^4.23.5",
|
"@nlpjs/nlp": "^4.23.5",
|
||||||
"@stdlib/regexp-regexp": "^0.0.6",
|
"@stdlib/regexp-regexp": "^0.0.6",
|
||||||
"@u4/opencv4nodejs": "^6.2.1",
|
|
||||||
"ajv": "^7.2.4",
|
"ajv": "^7.2.4",
|
||||||
"ansi-regex": ">=5.0.1",
|
"ansi-regex": ">=5.0.1",
|
||||||
"async": "^3.2.0",
|
"async": "^3.2.0",
|
||||||
@@ -165,7 +160,6 @@
|
|||||||
"better-sqlite3": "^7.5.0",
|
"better-sqlite3": "^7.5.0",
|
||||||
"mongo": "^3.6.0",
|
"mongo": "^3.6.0",
|
||||||
"mysql": "^2.18.1",
|
"mysql": "^2.18.1",
|
||||||
"opencv-build": "^0.1.9",
|
|
||||||
"pg": "^8.7.1",
|
"pg": "^8.7.1",
|
||||||
"sharp": "^0.29.1"
|
"sharp": "^0.29.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,12 @@ import {CancelDispatchAction, CancelDispatchActionJson} from "./CancelDispatchAc
|
|||||||
import ContributorAction, {ContributorActionJson} from "./ContributorAction";
|
import ContributorAction, {ContributorActionJson} from "./ContributorAction";
|
||||||
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
|
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||||
import {ModNoteAction, ModNoteActionJson} from "./ModNoteAction";
|
import {ModNoteAction, ModNoteActionJson} from "./ModNoteAction";
|
||||||
import {SubmissionAction, SubmissionActionJson} from "./SubmissionAction";
|
|
||||||
|
|
||||||
export function actionFactory
|
export function actionFactory
|
||||||
(config: StructuredActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap, emitter: EventEmitter): Action {
|
(config: StructuredActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap, emitter: EventEmitter): Action {
|
||||||
switch (config.kind) {
|
switch (config.kind) {
|
||||||
case 'comment':
|
case 'comment':
|
||||||
return new CommentAction({...config as StructuredFilter<CommentActionJson>, logger, subredditName, resources, client, emitter});
|
return new CommentAction({...config as StructuredFilter<CommentActionJson>, logger, subredditName, resources, client, emitter});
|
||||||
case 'submission':
|
|
||||||
return new SubmissionAction({...config as StructuredFilter<SubmissionActionJson>, logger, subredditName, resources, client, emitter});
|
|
||||||
case 'lock':
|
case 'lock':
|
||||||
return new LockAction({...config as StructuredFilter<LockActionJson>, logger, subredditName, resources, client, emitter});
|
return new LockAction({...config as StructuredFilter<LockActionJson>, logger, subredditName, resources, client, emitter});
|
||||||
case 'remove':
|
case 'remove':
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import Action, {ActionJson, ActionOptions} from "./index";
|
import Action, {ActionJson, ActionOptions} from "./index";
|
||||||
import {Comment, VoteableContent} from "snoowrap";
|
import {Comment} from "snoowrap";
|
||||||
import Submission from "snoowrap/dist/objects/Submission";
|
import Submission from "snoowrap/dist/objects/Submission";
|
||||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||||
import {ActionProcessResult, Footer, RequiredRichContent, RichContent, RuleResult} from "../Common/interfaces";
|
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 {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||||
import {runCheckOptions} from "../Subreddit/Manager";
|
import {runCheckOptions} from "../Subreddit/Manager";
|
||||||
import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infrastructure/Atomic";
|
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||||
import {CMError} from "../Utils/Errors";
|
|
||||||
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
|
||||||
|
|
||||||
export class CommentAction extends Action {
|
export class CommentAction extends Action {
|
||||||
content: string;
|
content: string;
|
||||||
@@ -16,7 +14,6 @@ export class CommentAction extends Action {
|
|||||||
sticky: boolean = false;
|
sticky: boolean = false;
|
||||||
distinguish: boolean = false;
|
distinguish: boolean = false;
|
||||||
footer?: false | string;
|
footer?: false | string;
|
||||||
targets: ArbitraryActionTarget[]
|
|
||||||
|
|
||||||
constructor(options: CommentActionOptions) {
|
constructor(options: CommentActionOptions) {
|
||||||
super(options);
|
super(options);
|
||||||
@@ -26,18 +23,12 @@ export class CommentAction extends Action {
|
|||||||
sticky = false,
|
sticky = false,
|
||||||
distinguish = false,
|
distinguish = false,
|
||||||
footer,
|
footer,
|
||||||
targets = ['self']
|
|
||||||
} = options;
|
} = options;
|
||||||
this.footer = footer;
|
this.footer = footer;
|
||||||
this.content = content;
|
this.content = content;
|
||||||
this.lock = lock;
|
this.lock = lock;
|
||||||
this.sticky = sticky;
|
this.sticky = sticky;
|
||||||
this.distinguish = distinguish;
|
this.distinguish = distinguish;
|
||||||
if (!Array.isArray(targets)) {
|
|
||||||
this.targets = [targets];
|
|
||||||
} else {
|
|
||||||
this.targets = targets;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getKind(): ActionTypes {
|
getKind(): ActionTypes {
|
||||||
@@ -54,105 +45,48 @@ export class CommentAction extends Action {
|
|||||||
const renderedContent = `${body}${footer}`;
|
const renderedContent = `${body}${footer}`;
|
||||||
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
|
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
|
||||||
|
|
||||||
let allErrors = true;
|
if(item.archived) {
|
||||||
const targetResults: string[] = [];
|
this.logger.warn('Cannot comment because Item is archived');
|
||||||
|
return {
|
||||||
|
dryRun,
|
||||||
|
success: false,
|
||||||
|
result: 'Cannot comment because Item is archived'
|
||||||
|
};
|
||||||
|
}
|
||||||
const touchedEntities = [];
|
const touchedEntities = [];
|
||||||
|
let modifiers = [];
|
||||||
for (const target of this.targets) {
|
let reply: Comment;
|
||||||
|
if(!dryRun) {
|
||||||
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(' | ')} == =>`;
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
targetResults.push(`${targetIdentifier}${modifierStr} created Comment ${dryRun ? 'DRYRUN' : (reply as SnoowrapActivity).name}`)
|
reply = await item.reply(renderedContent);
|
||||||
allErrors = false;
|
// 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 {
|
return {
|
||||||
dryRun,
|
dryRun,
|
||||||
success: !allErrors,
|
success: true,
|
||||||
result: `${targetResults.join('\n')}${truncateStringToLength(100)(body)}`,
|
result: `${modifierStr}${truncateStringToLength(100)(body)}`,
|
||||||
touchedEntities,
|
touchedEntities,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -163,8 +97,7 @@ export class CommentAction extends Action {
|
|||||||
lock: this.lock,
|
lock: this.lock,
|
||||||
sticky: this.sticky,
|
sticky: this.sticky,
|
||||||
distinguish: this.distinguish,
|
distinguish: this.distinguish,
|
||||||
footer: this.footer,
|
footer: this.footer
|
||||||
targets: this.targets,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,21 +115,6 @@ export interface CommentActionConfig extends RequiredRichContent, Footer {
|
|||||||
* Distinguish the comment after creation?
|
* Distinguish the comment after creation?
|
||||||
* */
|
* */
|
||||||
distinguish?: boolean,
|
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 {
|
export interface CommentActionOptions extends CommentActionConfig, ActionOptions {
|
||||||
@@ -206,5 +124,5 @@ export interface CommentActionOptions extends CommentActionConfig, ActionOptions
|
|||||||
* Reply to the Activity. For a submission the reply will be a top-level comment.
|
* Reply to the Activity. For a submission the reply will be a top-level comment.
|
||||||
* */
|
* */
|
||||||
export interface CommentActionJson extends CommentActionConfig, ActionJson {
|
export interface CommentActionJson extends CommentActionConfig, ActionJson {
|
||||||
kind: 'comment'
|
kind: 'comment'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,323 +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";
|
|
||||||
|
|
||||||
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[], options: runCheckOptions): Promise<ActionProcessResult> {
|
|
||||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
|
||||||
|
|
||||||
const title = await this.resources.renderContent(this.title, item, ruleResults);
|
|
||||||
this.logger.verbose(`Title: ${title}`);
|
|
||||||
|
|
||||||
const url = this.url !== undefined ? await this.resources.renderContent(this.url, item, ruleResults) : undefined;
|
|
||||||
|
|
||||||
this.logger.verbose(`URL: ${url !== undefined ? url : '[No URL]'}`);
|
|
||||||
|
|
||||||
const body = this.content !== undefined ? await this.resources.renderContent(this.content, item, ruleResults) : undefined;
|
|
||||||
|
|
||||||
let renderedContent: string | undefined = undefined;
|
|
||||||
if(body !== undefined) {
|
|
||||||
const footer = await this.resources.generateFooter(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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// @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
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
|
||||||
}
|
|
||||||
@@ -680,7 +680,6 @@ class Bot implements BotInstanceFunctions {
|
|||||||
databaseConfig: {
|
databaseConfig: {
|
||||||
retention = undefined
|
retention = undefined
|
||||||
} = {},
|
} = {},
|
||||||
wikiConfig = this.wikiLocation,
|
|
||||||
} = override || {};
|
} = override || {};
|
||||||
|
|
||||||
const subRepo = this.database.getRepository(SubredditEntity)
|
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, {
|
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {
|
||||||
dryRun: this.dryRun,
|
dryRun: this.dryRun,
|
||||||
sharedStreams: this.sharedStreams,
|
sharedStreams: this.sharedStreams,
|
||||||
wikiLocation: wikiConfig,
|
wikiLocation: this.wikiLocation,
|
||||||
botName: this.botName as string,
|
botName: this.botName as string,
|
||||||
maxWorkers: this.maxWorkers,
|
maxWorkers: this.maxWorkers,
|
||||||
filterCriteriaDefaults: this.filterCriteriaDefaults,
|
filterCriteriaDefaults: this.filterCriteriaDefaults,
|
||||||
|
|||||||
199
src/Common/ImageComparisonService.ts
Normal file
199
src/Common/ImageComparisonService.ts
Normal 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
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -42,8 +42,8 @@ class ImageData {
|
|||||||
return await (await this.sharp()).clone().toFormat(format).toBuffer();
|
return await (await this.sharp()).clone().toFormat(format).toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
async hash(bits: number = 16, useVariantIfPossible = true): Promise<Required<ImageHashCacheData>> {
|
async hash(bits: number = 16, useVariantIfPossible = true, force = false): Promise<Required<ImageHashCacheData>> {
|
||||||
if (this.hashResult === undefined || this.hashResultFlipped === undefined) {
|
if (force || (this.hashResult === undefined || this.hashResultFlipped === undefined)) {
|
||||||
let ref: ImageData | undefined;
|
let ref: ImageData | undefined;
|
||||||
if (useVariantIfPossible && this.preferredResolution !== undefined) {
|
if (useVariantIfPossible && this.preferredResolution !== undefined) {
|
||||||
ref = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
ref = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
||||||
@@ -182,6 +182,25 @@ class ImageData {
|
|||||||
return this.width === otherImage.width && this.height === otherImage.height;
|
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) {
|
async sameAspectRatio(otherImage: ImageData) {
|
||||||
let thisRes = this.actualResolution;
|
let thisRes = this.actualResolution;
|
||||||
let otherRes = otherImage.actualResolution;
|
let otherRes = otherImage.actualResolution;
|
||||||
@@ -207,12 +226,12 @@ class ImageData {
|
|||||||
return {width: width as number, height: height as number};
|
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();
|
const sFunc = await getSharpAsync();
|
||||||
|
|
||||||
let refImage = this as ImageData;
|
let refImage = this as ImageData;
|
||||||
let compareImage = imgToCompare;
|
let compareImage = imgToCompare;
|
||||||
if (this.preferredResolution !== undefined) {
|
if (usePreferredResolution && this.preferredResolution !== undefined) {
|
||||||
const matchingVariant = compareImage.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
const matchingVariant = compareImage.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
||||||
if (matchingVariant !== undefined) {
|
if (matchingVariant !== undefined) {
|
||||||
compareImage = matchingVariant;
|
compareImage = matchingVariant;
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ export type RecordOutputOption = boolean | RecordOutputType | RecordOutputType[]
|
|||||||
export type PostBehaviorType = 'next' | 'stop' | 'nextRun' | string;
|
export type PostBehaviorType = 'next' | 'stop' | 'nextRun' | string;
|
||||||
export type onExistingFoundBehavior = 'replace' | 'skip' | 'ignore';
|
export type onExistingFoundBehavior = 'replace' | 'skip' | 'ignore';
|
||||||
export type ActionTarget = 'self' | 'parent';
|
export type ActionTarget = 'self' | 'parent';
|
||||||
export type ArbitraryActionTarget = ActionTarget | string;
|
|
||||||
export type InclusiveActionTarget = ActionTarget | 'any';
|
export type InclusiveActionTarget = ActionTarget | 'any';
|
||||||
export type DispatchSource = 'dispatch' | `dispatch:${string}`;
|
export type DispatchSource = 'dispatch' | `dispatch:${string}`;
|
||||||
export type NonDispatchActivitySource = 'poll' | `poll:${PollOn}` | 'user' | `user:${string}`;
|
export type NonDispatchActivitySource = 'poll' | `poll:${PollOn}` | 'user' | `user:${string}`;
|
||||||
@@ -175,7 +174,6 @@ export type ActivitySource = NonDispatchActivitySource | DispatchSource;
|
|||||||
export type ConfigFormat = 'json' | 'yaml';
|
export type ConfigFormat = 'json' | 'yaml';
|
||||||
export type ActionTypes =
|
export type ActionTypes =
|
||||||
'comment'
|
'comment'
|
||||||
| 'submission'
|
|
||||||
| 'lock'
|
| 'lock'
|
||||||
| 'remove'
|
| 'remove'
|
||||||
| 'report'
|
| 'report'
|
||||||
|
|||||||
@@ -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> {
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import winston, {Logger} from "winston";
|
|
||||||
import {CMError} from "../Utils/Errors";
|
|
||||||
import {formatNumber, mergeArr, resolvePath} from "../util";
|
|
||||||
import * as cvTypes from '@u4/opencv4nodejs'
|
|
||||||
import ImageData from "./ImageData";
|
|
||||||
import {pathToFileURL} from "url";
|
|
||||||
|
|
||||||
let cv: any;
|
|
||||||
|
|
||||||
export const getCV = async (): Promise<typeof cvTypes.cv> => {
|
|
||||||
if (cv === undefined) {
|
|
||||||
try {
|
|
||||||
const cvImport = await import('@u4/opencv4nodejs');
|
|
||||||
if (cvImport === undefined) {
|
|
||||||
throw new CMError('Could not initialize openCV because opencv4nodejs is not installed');
|
|
||||||
}
|
|
||||||
cv = cvImport.default;
|
|
||||||
} catch (e: any) {
|
|
||||||
throw new CMError('Could not initialize openCV', {cause: e});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cv as typeof cvTypes.cv;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class OpenCVService {
|
|
||||||
|
|
||||||
logger: Logger;
|
|
||||||
|
|
||||||
constructor(logger?: Logger) {
|
|
||||||
const parentLogger = logger ?? winston.loggers.get('app');
|
|
||||||
this.logger = parentLogger.child({labels: ['OpenCV']}, mergeArr)
|
|
||||||
}
|
|
||||||
|
|
||||||
async cv() {
|
|
||||||
if (cv === undefined) {
|
|
||||||
try {
|
|
||||||
const cvImport = await import('@u4/opencv4nodejs');
|
|
||||||
if (cvImport === undefined) {
|
|
||||||
throw new CMError('Could not initialize openCV because opencv4nodejs is not installed');
|
|
||||||
}
|
|
||||||
cv = cvImport.default;
|
|
||||||
} catch (e: any) {
|
|
||||||
throw new CMError('Could not initialize openCV', {cause: e});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cv as typeof cvTypes.cv;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CurrentMaxData {
|
|
||||||
confidence: number,
|
|
||||||
loc: cvTypes.Point2,
|
|
||||||
ratio?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MatchResult {matchRec?: cvTypes.Rect, matchedConfidence?: number}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use openCV matchTemplate() to find images within images
|
|
||||||
*
|
|
||||||
* The majority of these code concepts are based on https://pyimagesearch.com/2015/01/26/multi-scale-template-matching-using-python-opencv/
|
|
||||||
* and examples/usage of opencv.js is from https://github.com/UrielCh/opencv4nodejs/tree/master/examples/src/templateMatch
|
|
||||||
*
|
|
||||||
* */
|
|
||||||
export class TemplateCompare {
|
|
||||||
cv: typeof cvTypes.cv;
|
|
||||||
logger: Logger;
|
|
||||||
|
|
||||||
template?: cvTypes.Mat;
|
|
||||||
downscaledTemplates: cvTypes.Mat[] = [];
|
|
||||||
|
|
||||||
constructor(cv: typeof cvTypes.cv, logger: Logger) {
|
|
||||||
this.cv = cv;
|
|
||||||
this.logger = logger.child({labels: ['OpenCV', 'Template Match']}, mergeArr)
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async normalizeImage(image: ImageData) {
|
|
||||||
return this.cv.imdecode(await ((await image.sharp()).clone().greyscale().toBuffer()));
|
|
||||||
}
|
|
||||||
|
|
||||||
async setTemplate(image: ImageData) {
|
|
||||||
this.template = await this.normalizeImage(image);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getTemplate() {
|
|
||||||
if (this.template === undefined) {
|
|
||||||
throw new Error('Template is not defined, use setTemplate() first');
|
|
||||||
}
|
|
||||||
return this.template.copy().canny(50, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
downscaleTemplates() {
|
|
||||||
if (this.template === undefined) {
|
|
||||||
throw new Error('Template is not defined, use setTemplate() first');
|
|
||||||
}
|
|
||||||
|
|
||||||
const [tH, tW] = this.template.sizes;
|
|
||||||
|
|
||||||
for (let i = 10; i <= 80; i += 10) {
|
|
||||||
const templateRatio = (100 - i) / 100;
|
|
||||||
|
|
||||||
// for debugging
|
|
||||||
// const scaled = this.template.copy().resize(new cv.Size(Math.floor(templateRatio * tW), Math.floor(templateRatio * tH))).canny(50, 200);
|
|
||||||
// const path = pathToFileURL(resolvePath(`./tests/assets/star/starTemplateScaled-${Math.floor(templateRatio * 100)}.jpg`, './')).pathname;
|
|
||||||
// cv.imwrite(path, scaled);
|
|
||||||
this.downscaledTemplates.push(this.template.copy().resize(new cv.Size(Math.floor(templateRatio * tW), Math.floor(templateRatio * tH))).canny(50, 200))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async matchImage(sourceImageData: ImageData, downscaleWhich: 'template' | 'image', confidence = 0.5): Promise<[boolean, MatchResult]> {
|
|
||||||
if (this.template === undefined) {
|
|
||||||
throw new Error('Template is not defined, use setTemplate() first');
|
|
||||||
}
|
|
||||||
|
|
||||||
let currMax: CurrentMaxData | undefined;
|
|
||||||
|
|
||||||
let matchRec: cvTypes.Rect | undefined;
|
|
||||||
let matchedConfidence: number | undefined;
|
|
||||||
|
|
||||||
if (downscaleWhich === 'template') {
|
|
||||||
// in this scenario we assume our template is a significant fraction of the size of the source
|
|
||||||
// so we want to scale down the template size incrementally
|
|
||||||
// because we are assuming the template in the image is smaller than our source template
|
|
||||||
|
|
||||||
// generate scaled templates and save for later use!
|
|
||||||
// its likely this class is in use in Recent/Repeat rules which means we will probably be comparing this template against many images
|
|
||||||
if (this.downscaledTemplates.length === 0) {
|
|
||||||
this.downscaleTemplates();
|
|
||||||
}
|
|
||||||
|
|
||||||
let currMaxTemplateSize: number[] | undefined;
|
|
||||||
|
|
||||||
const src = (await this.normalizeImage(sourceImageData)).canny(50, 200);
|
|
||||||
|
|
||||||
const edgedTemplate = await this.getTemplate();
|
|
||||||
|
|
||||||
for (const scaledTemplate of [edgedTemplate].concat(this.downscaledTemplates)) {
|
|
||||||
|
|
||||||
// more information on methods...
|
|
||||||
// https://docs.opencv.org/4.x/d4/dc6/tutorial_py_template_matching.html
|
|
||||||
// https://stackoverflow.com/questions/58158129/understanding-and-evaluating-template-matching-methods
|
|
||||||
// https://stackoverflow.com/questions/48799711/explain-difference-between-opencvs-template-matching-methods-in-non-mathematica
|
|
||||||
// https://datahacker.rs/014-template-matching-using-opencv-in-python/
|
|
||||||
// ...may want to try with TM_SQDIFF but will need to use minimum values instead of max
|
|
||||||
const result = src.matchTemplate(scaledTemplate, cv.TM_CCOEFF_NORMED);
|
|
||||||
|
|
||||||
const minMax = result.minMaxLoc();
|
|
||||||
const {maxVal, maxLoc} = minMax;
|
|
||||||
|
|
||||||
if (currMax === undefined || maxVal > currMax.confidence) {
|
|
||||||
currMaxTemplateSize = scaledTemplate.sizes;
|
|
||||||
currMax = {confidence: maxVal, loc: maxLoc};
|
|
||||||
console.log(`New Best Max Confidence: ${formatNumber(maxVal, {toFixed: 4})}`)
|
|
||||||
}
|
|
||||||
if (maxVal >= confidence) {
|
|
||||||
this.logger.verbose(`Match with confidence ${formatNumber(maxVal, {toFixed: 4})} met threshold of ${confidence}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currMax !== undefined) {
|
|
||||||
matchedConfidence = currMax.confidence;
|
|
||||||
|
|
||||||
if (currMaxTemplateSize !== undefined) {
|
|
||||||
const startX = currMax.loc.x;
|
|
||||||
const startY = currMax.loc.y;
|
|
||||||
|
|
||||||
matchRec = new cv.Rect(startX, startY, currMaxTemplateSize[1], currMaxTemplateSize[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
} else {
|
|
||||||
// in this scenario we assume our template is small, compared to the source image
|
|
||||||
// and the template found in the source is likely larger than the template
|
|
||||||
// so we scale down the source incrementally to try to get them to match
|
|
||||||
|
|
||||||
const normalSrc = (await this.normalizeImage(sourceImageData));
|
|
||||||
let src = normalSrc.copy();
|
|
||||||
const [width, height] = src.sizes;
|
|
||||||
|
|
||||||
const edgedTemplate = await this.getTemplate();
|
|
||||||
const [tH, tW] = edgedTemplate.sizes;
|
|
||||||
|
|
||||||
let ratio = 1;
|
|
||||||
|
|
||||||
for (let i = 0; i <= 80; i += 5) {
|
|
||||||
ratio = (100 - i) / 100;
|
|
||||||
|
|
||||||
if (i !== 100) {
|
|
||||||
const resizedWidth = Math.floor(width * ratio);
|
|
||||||
const resizedHeight = Math.floor(height * ratio);
|
|
||||||
src = src.resize(new cv.Size(resizedWidth, resizedHeight));
|
|
||||||
}
|
|
||||||
|
|
||||||
const [sH, sW] = src.sizes;
|
|
||||||
if (sH < tH || sW < tW) {
|
|
||||||
// scaled source is smaller than template
|
|
||||||
this.logger.debug(`Template matching ended early due to downscaled image being smaller than template`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const edged = src.canny(50, 200);
|
|
||||||
const result = edged.matchTemplate(edgedTemplate, cv.TM_CCOEFF_NORMED);
|
|
||||||
|
|
||||||
const minMax = result.minMaxLoc();
|
|
||||||
const {maxVal, maxLoc} = minMax;
|
|
||||||
|
|
||||||
if (currMax === undefined || maxVal > currMax.confidence) {
|
|
||||||
currMax = {confidence: maxVal, loc: maxLoc, ratio};
|
|
||||||
console.log(`New Best Confidence: ${formatNumber(maxVal, {toFixed: 4})}`)
|
|
||||||
}
|
|
||||||
if (maxVal >= confidence) {
|
|
||||||
this.logger.verbose(`Match with confidence ${formatNumber(maxVal, {toFixed: 4})} met threshold of ${confidence}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currMax === undefined) {
|
|
||||||
// template was larger than source
|
|
||||||
this.logger.debug('No local max found');
|
|
||||||
} else {
|
|
||||||
const maxRatio = currMax.ratio as number;
|
|
||||||
|
|
||||||
const startX = currMax.loc.x * (1 / maxRatio);
|
|
||||||
const startY = currMax.loc.y * (1 / maxRatio);
|
|
||||||
|
|
||||||
const endWidth = tW * (1 / maxRatio);
|
|
||||||
const endHeight = tH * (1 / maxRatio);
|
|
||||||
|
|
||||||
matchRec = new cv.Rect(startX, startY, endWidth, endHeight);
|
|
||||||
matchedConfidence = currMax.confidence;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currMax !== undefined) {
|
|
||||||
return [currMax.confidence >= confidence, {matchRec, matchedConfidence}]
|
|
||||||
}
|
|
||||||
return [false, {matchRec, matchedConfidence}]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,4 +42,4 @@ export const filterCriteriaDefault: FilterCriteriaDefaults = {
|
|||||||
export const defaultDataDir = path.resolve(__dirname, '../..');
|
export const defaultDataDir = path.resolve(__dirname, '../..');
|
||||||
export const defaultConfigFilenames = ['config.json', 'config.yaml'];
|
export const defaultConfigFilenames = ['config.json', 'config.yaml'];
|
||||||
|
|
||||||
export const VERSION = '0.12.0';
|
export const VERSION = '0.11.4';
|
||||||
|
|||||||
@@ -1060,16 +1060,6 @@ export interface SubredditOverrides {
|
|||||||
* */
|
* */
|
||||||
retention?: EventRetentionPolicyRange
|
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import {ContributorActionJson} from "../Action/ContributorAction";
|
|||||||
import {SentimentRuleJSONConfig} from "../Rule/SentimentRule";
|
import {SentimentRuleJSONConfig} from "../Rule/SentimentRule";
|
||||||
import {ModNoteActionJson} from "../Action/ModNoteAction";
|
import {ModNoteActionJson} from "../Action/ModNoteAction";
|
||||||
import {IncludesData} from "./Infrastructure/Includes";
|
import {IncludesData} from "./Infrastructure/Includes";
|
||||||
import { SubmissionActionJson } from "../Action/SubmissionAction";
|
|
||||||
|
|
||||||
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig
|
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -46,9 +46,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/definitions/ModNoteActionJson"
|
"$ref": "#/definitions/ModNoteActionJson"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/definitions/SubmissionActionJson"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -707,20 +704,6 @@
|
|||||||
"sticky": {
|
"sticky": {
|
||||||
"description": "Stick the comment after creation?",
|
"description": "Stick the comment after creation?",
|
||||||
"type": "boolean"
|
"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": [
|
"required": [
|
||||||
@@ -2309,160 +2292,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
|
||||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"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": {
|
"SubmissionState": {
|
||||||
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
|
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
|
||||||
"examples": [
|
"examples": [
|
||||||
|
|||||||
@@ -1376,20 +1376,6 @@
|
|||||||
"sticky": {
|
"sticky": {
|
||||||
"description": "Stick the comment after creation?",
|
"description": "Stick the comment after creation?",
|
||||||
"type": "boolean"
|
"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": [
|
"required": [
|
||||||
@@ -1461,9 +1447,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/definitions/ModNoteActionJson"
|
"$ref": "#/definitions/ModNoteActionJson"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/definitions/SubmissionActionJson"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -5678,160 +5661,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
|
||||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"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": {
|
"SubmissionCheckConfigData": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -5895,9 +5724,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/definitions/ModNoteActionJson"
|
"$ref": "#/definitions/ModNoteActionJson"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/definitions/SubmissionActionJson"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1199,20 +1199,6 @@
|
|||||||
"sticky": {
|
"sticky": {
|
||||||
"description": "Stick the comment after creation?",
|
"description": "Stick the comment after creation?",
|
||||||
"type": "boolean"
|
"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": [
|
"required": [
|
||||||
@@ -1284,9 +1270,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/definitions/ModNoteActionJson"
|
"$ref": "#/definitions/ModNoteActionJson"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/definitions/SubmissionActionJson"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -5003,160 +4986,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
|
||||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"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": {
|
"SubmissionCheckConfigData": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -5220,9 +5049,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/definitions/ModNoteActionJson"
|
"$ref": "#/definitions/ModNoteActionJson"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/definitions/SubmissionActionJson"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2030,14 +2030,6 @@
|
|||||||
},
|
},
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"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": [
|
"required": [
|
||||||
|
|||||||
@@ -1196,20 +1196,6 @@
|
|||||||
"sticky": {
|
"sticky": {
|
||||||
"description": "Stick the comment after creation?",
|
"description": "Stick the comment after creation?",
|
||||||
"type": "boolean"
|
"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": [
|
"required": [
|
||||||
@@ -1281,9 +1267,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/definitions/ModNoteActionJson"
|
"$ref": "#/definitions/ModNoteActionJson"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/definitions/SubmissionActionJson"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -5249,160 +5232,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"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": {
|
|
||||||
"description": "Specify where this Submission should be made\n\nValid values: 'self' | [subreddit]\n\n* 'self' -- DEFAULT. Post Submission to same subreddit of Activity being processed\n* [subreddit] -- The name of a subreddit to post Submission to. EX mealtimevideos",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"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": {
|
"SubmissionCheckConfigData": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -5466,9 +5295,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/definitions/ModNoteActionJson"
|
"$ref": "#/definitions/ModNoteActionJson"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/definitions/SubmissionActionJson"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -994,7 +994,7 @@ export class SubredditResources {
|
|||||||
hash = `sub-${item.name}`;
|
hash = `sub-${item.name}`;
|
||||||
if (tryToFetch && item instanceof Submission) {
|
if (tryToFetch && item instanceof Submission) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const itemToCache = await item.refresh();
|
const itemToCache = await item.fetch();
|
||||||
await this.cache.set(hash, itemToCache, {ttl: this.submissionTTL});
|
await this.cache.set(hash, itemToCache, {ttl: this.submissionTTL});
|
||||||
return itemToCache;
|
return itemToCache;
|
||||||
} else {
|
} else {
|
||||||
@@ -1006,7 +1006,7 @@ export class SubredditResources {
|
|||||||
hash = `comm-${item.name}`;
|
hash = `comm-${item.name}`;
|
||||||
if (tryToFetch && item instanceof Comment) {
|
if (tryToFetch && item instanceof Comment) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const itemToCache = await item.refresh();
|
const itemToCache = await item.fetch();
|
||||||
await this.cache.set(hash, itemToCache, {ttl: this.commentTTL});
|
await this.cache.set(hash, itemToCache, {ttl: this.commentTTL});
|
||||||
return itemToCache;
|
return itemToCache;
|
||||||
} else {
|
} else {
|
||||||
@@ -1016,12 +1016,8 @@ export class SubredditResources {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return item;
|
return item;
|
||||||
} catch (e: any) {
|
} catch (e) {
|
||||||
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', {cause: e});
|
||||||
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});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1106,9 +1102,8 @@ export class SubredditResources {
|
|||||||
return subreddit as Subreddit;
|
return subreddit as Subreddit;
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const cmError = new CMError('Error while trying to fetch a cached subreddit', {cause: err, logged: true});
|
this.logger.error('Error while trying to fetch a cached subreddit', err);
|
||||||
this.logger.error(cmError);
|
throw err.logged;
|
||||||
throw cmError;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 809 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 45 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB |
@@ -1,75 +0,0 @@
|
|||||||
import {describe, it} from 'mocha';
|
|
||||||
import chai from 'chai';
|
|
||||||
import chaiAsPromised from 'chai-as-promised';
|
|
||||||
import express, {Request, Response} from "express";
|
|
||||||
import {formatNumber, resolvePath, sleep} from "../src/util";
|
|
||||||
import {pathToFileURL, URL} from "url";
|
|
||||||
import ImageData from "../src/Common/ImageData";
|
|
||||||
import * as cvTypes from '@u4/opencv4nodejs'
|
|
||||||
import {getCV, TemplateCompare} from "../src/Common/OpenCVService";
|
|
||||||
import winston from 'winston';
|
|
||||||
|
|
||||||
chai.use(chaiAsPromised);
|
|
||||||
|
|
||||||
const assert = chai.assert;
|
|
||||||
|
|
||||||
const star = pathToFileURL(resolvePath('./tests/assets/star-transparent.png', './'));
|
|
||||||
const starInside = pathToFileURL(resolvePath('./tests/assets/star-inside.png', './'));
|
|
||||||
const tran = pathToFileURL(resolvePath('./tests/assets/tran.jpg', './'));
|
|
||||||
const tranSel = pathToFileURL(resolvePath('./tests/assets/tran-selection.jpg', './'));
|
|
||||||
|
|
||||||
describe('Template Matching', function () {
|
|
||||||
|
|
||||||
let cv: typeof cvTypes.cv;
|
|
||||||
|
|
||||||
before(async () => {
|
|
||||||
cv = await getCV();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches a standard example', async function () {
|
|
||||||
|
|
||||||
const templateMatch = new TemplateCompare(cv, winston.loggers.get('app'));
|
|
||||||
|
|
||||||
await templateMatch.setTemplate(new ImageData({path: tranSel}));
|
|
||||||
|
|
||||||
const [passed, results] = await templateMatch.matchImage(new ImageData({
|
|
||||||
path: tran
|
|
||||||
}), 'template');
|
|
||||||
|
|
||||||
if(results.matchRec !== undefined) {
|
|
||||||
const src = cv.imread(tran.pathname);
|
|
||||||
src.drawRectangle(
|
|
||||||
results.matchRec,
|
|
||||||
new cv.Vec3(0, 255, 0),
|
|
||||||
2,
|
|
||||||
cv.LINE_8
|
|
||||||
);
|
|
||||||
// TODO mask is not drawn correctly (its above?)
|
|
||||||
cv.imwrite(pathToFileURL(resolvePath(`./tests/assets/tran-masked.jpg`, './')).pathname, src);
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.isTrue(passed);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('matches a template using service', async function () {
|
|
||||||
|
|
||||||
const templateMatch = new TemplateCompare(cv, winston.loggers.get('app'));
|
|
||||||
|
|
||||||
await templateMatch.setTemplate(new ImageData({path: star}));
|
|
||||||
|
|
||||||
const [passed, results] = await templateMatch.matchImage(new ImageData({
|
|
||||||
path: starInside
|
|
||||||
}), 'template', 0.2);
|
|
||||||
|
|
||||||
if(results.matchRec !== undefined) {
|
|
||||||
const src = cv.imread(starInside.pathname);
|
|
||||||
src.drawRectangle(
|
|
||||||
results.matchRec,
|
|
||||||
new cv.Vec3(0, 255, 0),
|
|
||||||
2,
|
|
||||||
cv.LINE_8
|
|
||||||
);
|
|
||||||
cv.imwrite(pathToFileURL(resolvePath(`./tests/assets/star-masked.jpg`, './')).pathname, src);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user