Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea875e392f | ||
|
|
b89d98e42b | ||
|
|
c02a2ad622 | ||
|
|
e9f08915a4 | ||
|
|
4b95ccd0ba | ||
|
|
567a2f0720 | ||
|
|
61ff402256 | ||
|
|
9158bda992 | ||
|
|
873f9d3c91 | ||
|
|
783ef5db53 | ||
|
|
f0cb5c1315 | ||
|
|
8e1b916ea4 | ||
|
|
4acf87eacd | ||
|
|
5dd5a32c51 | ||
|
|
207907881f | ||
|
|
44da276d41 | ||
|
|
6c98b6f995 | ||
|
|
cc0c3dfe61 | ||
|
|
b48d75fda3 | ||
|
|
2adf2d258d | ||
|
|
c55a1c6502 | ||
|
|
0011ff8853 | ||
|
|
4bbf871051 | ||
|
|
54755dc480 | ||
|
|
01f95a37e7 | ||
|
|
5ddad418b0 | ||
|
|
b5b2e88c1f | ||
|
|
194ded7be6 | ||
|
|
7ba375d702 | ||
|
|
9a4c38151f | ||
|
|
f8df6fc93f | ||
|
|
86a3b229cb | ||
|
|
ca3e8d7d80 | ||
|
|
5af4384871 | ||
|
|
47957e6ab9 | ||
|
|
7f1a404b4e | ||
|
|
1f64a56260 | ||
|
|
366cb2b629 | ||
|
|
b9b442ad1e | ||
|
|
81a1bdb446 | ||
|
|
1e4b369b1e | ||
|
|
7a34a7b531 | ||
|
|
b83bb6f998 | ||
|
|
3348af2780 | ||
|
|
04896a7363 |
12
Dockerfile
@@ -81,8 +81,9 @@ RUN apk add --no-cache \
|
||||
#
|
||||
|
||||
# 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 \
|
||||
&& apk --no-cache add vips
|
||||
&& apk --no-cache add vips opencv opencv-dev
|
||||
|
||||
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
||||
|
||||
@@ -115,6 +116,15 @@ RUN npm install --production \
|
||||
&& rm -rf node_modules/ts-node \
|
||||
&& 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
|
||||
|
||||
# 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
|
||||
|
||||
CM is composed of two applications that operate indepedently but are packaged together such that they act as one piece of software:
|
||||
CM is composed of two applications that operate independently but are packaged together such that they act as one piece of software:
|
||||
|
||||
* **Server** -- Responsible for **running the bot(s)** and providing an API to retrieve information on and interact with them EX start/stop bot, reload config, retrieve operational status, etc.
|
||||
* **Client** -- Responsible for serving the **web interface** and handling the bot oauth authentication flow between operators and subreddits/bots.
|
||||
|
||||
@@ -136,6 +136,8 @@ You will need have this information available:
|
||||
|
||||
See the [**example minimum configuration** below.](#minimum-config)
|
||||
|
||||
This configuration can also be **generated** by CM if you start CM with **no configuration defined** and visit the web interface.
|
||||
|
||||
# Bots
|
||||
|
||||
Configured using the `bots` top-level property. Bot configuration can override and specify many more options than are available at the operator-level. Many of these can also set the defaults for each subreddit the bot runs:
|
||||
|
||||
@@ -4,9 +4,7 @@ This getting started guide is for **Operators** -- that is, someone who wants to
|
||||
|
||||
* [Installation](#installation)
|
||||
* [Create a Reddit Client](#create-a-reddit-client)
|
||||
* [Create a Minimum Configuration](#create-a-minimum-configuration)
|
||||
* [Local Installation](#local-installation)
|
||||
* [Docker Installation](#docker-installation)
|
||||
* [Start ContextMod](#start-contextmod)
|
||||
* [Add a Bot to CM](#add-a-bot-to-cm)
|
||||
* [Access The Dashboard](#access-the-dashboard)
|
||||
* [What's Next?](#whats-next)
|
||||
@@ -19,29 +17,25 @@ 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 Minimum Configuration
|
||||
# Start ContextMod
|
||||
|
||||
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.
|
||||
Start CM using the example command from your [installation](#installation) and visit http://localhost:8085
|
||||
|
||||
# Start ContextMod With Configuration
|
||||
The First Time Setup page will ask you to input:
|
||||
|
||||
## Local Installation
|
||||
* Client ID (from [Create a Reddit Client](#create-a-reddit-client))
|
||||
* Client Secret (from [Create a Reddit Client](#create-a-reddit-client))
|
||||
* Operator -- this is the username of your main Reddit account.
|
||||
|
||||
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)
|
||||
**Write Config** and then restart CM. You have now created the [minimum configuration](/docs/operator/configuration.md#minimum-configuration) required to run CM.
|
||||
|
||||
# Add A Bot to CM
|
||||
|
||||
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.
|
||||
You should automatically be directed to the [Bot Invite Helper](/docs/operator/addingBot.md#cm-oauth-helper-recommended) used to authorize and add a Bot to your CM instance.
|
||||
|
||||
Follow the directions here and **create an Authorization Invite** at the bottom of the page.
|
||||
|
||||
Next, login to Reddit with the account you will be using as the Bot and then visit the **Authorization Invite** link you created. Follow the steps there to finish adding the Bot to your CM instance.
|
||||
|
||||
# Access The Dashboard
|
||||
|
||||
@@ -57,4 +51,4 @@ As an operator you should familiarize yourself with how the [operator configurat
|
||||
|
||||
If you are also the moderator of the subreddit the bot will be running you should check out the [moderator getting started guide.](/docs/subreddit/gettingStarted.md#setup-wiki-page)
|
||||
|
||||
You might also be interested in these [quick tips for using the web interface](/docs/webInterface.md)
|
||||
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.
|
||||
|
||||
@@ -10,9 +10,10 @@ PROTIP: Using a container management tool like [Portainer.io CE](https://www.por
|
||||
|
||||
### [Dockerhub](https://hub.docker.com/r/foxxmd/context-mod)
|
||||
|
||||
An example of starting the container using the [minimum configuration](/docs/operator/operatorConfiguration.md#minimum-config) with a [configuration file](/docs/operator/operatorConfiguration.md#defining-configuration-via-file):
|
||||
An example of starting the container using the [minimum configuration](/docs/operator/configuration.md#minimum-config):
|
||||
|
||||
* Bind the directory where your config file, logs, and database are located on your host machine into the container's default `DATA_DIR` by using `-v /host/path/folder:/config`
|
||||
* Note: **You must do this** or else your configuration will be lost next time your container is updated.
|
||||
* Expose the web interface using the container port `8085`
|
||||
|
||||
```
|
||||
@@ -59,6 +60,65 @@ An example of running CM using the [minimum configuration](/docs/operator/config
|
||||
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)
|
||||
|
||||
**NOTE:** This is still experimental and requires more testing.
|
||||
|
||||
@@ -29,6 +29,7 @@ This list is not exhaustive. [For complete documentation on a subreddit's config
|
||||
* [List of Actions](#list-of-actions)
|
||||
* [Approve](#approve)
|
||||
* [Ban](#ban)
|
||||
* [Submission](#submission)
|
||||
* [Comment](#comment)
|
||||
* [Contributor (Add/Remove)](#contributor)
|
||||
* [Dispatch/Delay](#dispatch)
|
||||
@@ -494,11 +495,30 @@ actions:
|
||||
|
||||
### Comment
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
* If the Activity is a Submission the comment is a top-level reply
|
||||
* If the Activity is a Comment the comment is a child reply
|
||||
|
||||
#### Templating
|
||||
|
||||
`content` can be [templated](#templating) and use [URL Tokens](#url-tokens)
|
||||
|
||||
#### Targets
|
||||
|
||||
Optionally, specify the Activity CM should reply to. **When not specified CM replies to the Activity being processed using `self`**
|
||||
|
||||
Valid values: `self`, `parent`, or a Reddit permalink.
|
||||
|
||||
`self` and `parent` are special targets that are relative to the Activity being processed:
|
||||
|
||||
* When the Activity being processed is a **Submission** => `parent` logs a warning and does nothing
|
||||
* When the Activity being processed is a **Comment**
|
||||
* `self` => reply to Comment
|
||||
* `parent` => make a top-level Comment in the **Submission** the Comment belong to
|
||||
|
||||
If target is not self/parent then CM assumes the value is a **reddit permalink** and will attempt to make a Comment to that Activity
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: comment
|
||||
@@ -506,7 +526,71 @@ actions:
|
||||
distinguish: boolean # distinguish as a mod
|
||||
sticky: boolean # sticky comment
|
||||
lock: boolean # lock the comment after creation
|
||||
targets: string # 'self' or 'parent' or 'https://reddit.com/r/someSubreddit/21nfdi....'
|
||||
```
|
||||
|
||||
### Submission
|
||||
|
||||
Create a Submission [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FSubmissionActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)
|
||||
|
||||
The Submission type, Link or Self-Post, is determined based on the presence of `url` in the action's configuration.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: submission
|
||||
title: string # required, the title of the submission. can be templated.
|
||||
content: string # the body of the submission. can be templated
|
||||
url: string # if specified the submission will be a Link Submission. can be templated
|
||||
distinguish: boolean # distinguish as a mod
|
||||
sticky: boolean # sticky comment
|
||||
lock: boolean # lock the comment after creation
|
||||
nsfw: boolean # mark submission as NSFW
|
||||
spoiler: boolean # mark submission as a spoiler
|
||||
flairId: string # flair template id for submission
|
||||
flairText: string # flair text for submission
|
||||
targets: string # 'self' or a subreddit name IE mealtimevideos
|
||||
```
|
||||
|
||||
#### Templating
|
||||
|
||||
`content`,`url`, and `title` can be [templated](#templating) and use [URL Tokens](#url-tokens)
|
||||
|
||||
TIP: To create a Link Submission pointing to the Activity currently being processed use
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: submission
|
||||
url: {{item.permalink}}
|
||||
# ...
|
||||
```
|
||||
|
||||
#### Targets
|
||||
|
||||
Optionally, specify the Subreddit the Submission should be made in. **When not specified CM uses `self`**
|
||||
|
||||
Valid values: `self` or Subreddit Name
|
||||
|
||||
* `self` => (**Default**) Create Submission in the same Subreddit of the Activity being processed
|
||||
* Subreddit Name => Create Submission in given subreddit IE `mealtimevideos`
|
||||
* Your bot must be able to access and be able to post in the given subreddit
|
||||
|
||||
Example:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: comment
|
||||
targets: mealtimevideos
|
||||
```
|
||||
|
||||
To post to multiple subreddits use a list:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: comment
|
||||
targets:
|
||||
- self
|
||||
- mealtimevideos
|
||||
- anotherSubreddit
|
||||
```
|
||||
|
||||
### Contributor
|
||||
@@ -623,16 +707,19 @@ actions:
|
||||
|
||||
Remove the Activity being processed. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FRemoveActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json)
|
||||
|
||||
* **note** can be [templated](#templating)
|
||||
* **reasonId** IDs can be found in the [editor](/docs/webInterface.md) using the **Removal Reasons** popup
|
||||
|
||||
If neither note nor reasonId are included then no removal reason is added.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- kind: remove
|
||||
spam: boolean # optional, mark as spam on removal
|
||||
spam: false # optional, mark as spam on removal
|
||||
note: 'a moderator-readable note' # optional, a note only visible to moderators (new reddit only)
|
||||
reasonId: '2n0f4674-365e-46d2-8fc7-a337d85d5340' # optional, the ID of a removal reason to add to the removal action (new reddit only)
|
||||
```
|
||||
|
||||
#### What About Removal Reason?
|
||||
|
||||
Reddit does not support setting a removal reason through the API. Please complain in [r/modsupport](https://www.reddit.com/r/modsupport) or [r/redditdev](https://www.reddit.com/r/redditdev) to help get this added :)
|
||||
|
||||
### Report
|
||||
|
||||
Report the Activity being processed. [Schema Documentation](https://json-schema.app/view/%23/%23%2Fdefinitions%2FSubmissionCheckJson/%23%2Fdefinitions%2FReportActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fedge%2Fsrc%2FSchema%2FApp.json)
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
## Web Dashboard Tips
|
||||
|
||||
* Click the **Help** button at the top of the page to get a **guided tour of the dashboard**
|
||||
* Use the [**Overview** section](/docs/images/botOperations.png) to control the bot at a high-level
|
||||
* You can **manually run** the bot on any activity (comment/submission) by pasting its permalink into the [input field below the Overview section](/docs/images/runInput.png) and hitting one of the **run buttons**
|
||||
* **Dry run** will make the bot run on the activity but it will only **pretend** to run actions, if triggered. This is super useful for testing your config without consequences
|
||||
|
||||
1958
package-lock.json
generated
@@ -21,7 +21,11 @@
|
||||
"circular-graph": "madge --image graph.svg --circular --extensions ts src/index.ts",
|
||||
"postinstall": "patch-package",
|
||||
"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": {
|
||||
"node": ">=16"
|
||||
@@ -42,6 +46,7 @@
|
||||
"@nlpjs/language": "^4.22.7",
|
||||
"@nlpjs/nlp": "^4.23.5",
|
||||
"@stdlib/regexp-regexp": "^0.0.6",
|
||||
"@u4/opencv4nodejs": "^6.2.1",
|
||||
"ajv": "^7.2.4",
|
||||
"ansi-regex": ">=5.0.1",
|
||||
"async": "^3.2.0",
|
||||
@@ -97,6 +102,7 @@
|
||||
"triple-beam": "^1.3.0",
|
||||
"typeorm": "^0.3.7",
|
||||
"typeorm-logger-adaptor": "^1.1.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"vader-sentiment": "^1.1.3",
|
||||
"webhook-discord": "^3.7.7",
|
||||
"wink-sentiment": "^5.0.2",
|
||||
@@ -159,6 +165,7 @@
|
||||
"better-sqlite3": "^7.5.0",
|
||||
"mongo": "^3.6.0",
|
||||
"mysql": "^2.18.1",
|
||||
"opencv-build": "^0.1.9",
|
||||
"pg": "^8.7.1",
|
||||
"sharp": "^0.29.1"
|
||||
}
|
||||
|
||||
@@ -18,12 +18,15 @@ import {CancelDispatchAction, CancelDispatchActionJson} from "./CancelDispatchAc
|
||||
import ContributorAction, {ContributorActionJson} from "./ContributorAction";
|
||||
import {StructuredFilter} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {ModNoteAction, ModNoteActionJson} from "./ModNoteAction";
|
||||
import {SubmissionAction, SubmissionActionJson} from "./SubmissionAction";
|
||||
|
||||
export function actionFactory
|
||||
(config: StructuredActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap, emitter: EventEmitter): Action {
|
||||
switch (config.kind) {
|
||||
case 'comment':
|
||||
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':
|
||||
return new LockAction({...config as StructuredFilter<LockActionJson>, logger, subredditName, resources, client, emitter});
|
||||
case 'remove':
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import Action, {ActionJson, ActionOptions} from "./index";
|
||||
import {Comment} from "snoowrap";
|
||||
import {Comment, 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 {truncateStringToLength} from "../util";
|
||||
import {asComment, asSubmission, parseRedditThingsFromLink, truncateStringToLength} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
import {ActionTarget, ActionTypes, ArbitraryActionTarget} from "../Common/Infrastructure/Atomic";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
import {SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
||||
|
||||
export class CommentAction extends Action {
|
||||
content: string;
|
||||
@@ -14,6 +16,7 @@ export class CommentAction extends Action {
|
||||
sticky: boolean = false;
|
||||
distinguish: boolean = false;
|
||||
footer?: false | string;
|
||||
targets: ArbitraryActionTarget[]
|
||||
|
||||
constructor(options: CommentActionOptions) {
|
||||
super(options);
|
||||
@@ -23,12 +26,18 @@ export class CommentAction extends Action {
|
||||
sticky = false,
|
||||
distinguish = false,
|
||||
footer,
|
||||
targets = ['self']
|
||||
} = options;
|
||||
this.footer = footer;
|
||||
this.content = content;
|
||||
this.lock = lock;
|
||||
this.sticky = sticky;
|
||||
this.distinguish = distinguish;
|
||||
if (!Array.isArray(targets)) {
|
||||
this.targets = [targets];
|
||||
} else {
|
||||
this.targets = targets;
|
||||
}
|
||||
}
|
||||
|
||||
getKind(): ActionTypes {
|
||||
@@ -45,48 +54,105 @@ export class CommentAction extends Action {
|
||||
const renderedContent = `${body}${footer}`;
|
||||
this.logger.verbose(`Contents:\r\n${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`);
|
||||
|
||||
if(item.archived) {
|
||||
this.logger.warn('Cannot comment because Item is archived');
|
||||
return {
|
||||
dryRun,
|
||||
success: false,
|
||||
result: 'Cannot comment because Item is archived'
|
||||
};
|
||||
}
|
||||
let allErrors = true;
|
||||
const targetResults: string[] = [];
|
||||
const touchedEntities = [];
|
||||
let modifiers = [];
|
||||
let reply: Comment;
|
||||
if(!dryRun) {
|
||||
// @ts-ignore
|
||||
reply = await item.reply(renderedContent);
|
||||
// add to recent so we ignore activity when/if it is discovered by polling
|
||||
await this.resources.setRecentSelf(reply);
|
||||
touchedEntities.push(reply);
|
||||
}
|
||||
if (this.lock) {
|
||||
modifiers.push('Locked');
|
||||
|
||||
for (const target of this.targets) {
|
||||
|
||||
let targetItem = item;
|
||||
let targetIdentifier = target;
|
||||
|
||||
if (target === 'parent') {
|
||||
if (asSubmission(item)) {
|
||||
const noParent = `[Parent] Submission ${item.name} does not have a parent`;
|
||||
this.logger.warn(noParent);
|
||||
targetResults.push(noParent);
|
||||
continue;
|
||||
}
|
||||
targetItem = await this.resources.getActivity(this.client.getSubmission(item.link_id));
|
||||
} else if (target !== 'self') {
|
||||
const redditThings = parseRedditThingsFromLink(target);
|
||||
let id = '';
|
||||
|
||||
try {
|
||||
if (redditThings.comment !== undefined) {
|
||||
id = redditThings.comment.id;
|
||||
targetIdentifier = `Permalink Comment ${id}`
|
||||
// @ts-ignore
|
||||
await this.resources.getActivity(this.client.getSubmission(redditThings.submission.id));
|
||||
targetItem = await this.resources.getActivity(this.client.getComment(redditThings.comment.id));
|
||||
} else if (redditThings.submission !== undefined) {
|
||||
id = redditThings.submission.id;
|
||||
targetIdentifier = `Permalink Submission ${id}`
|
||||
targetItem = await this.resources.getActivity(this.client.getSubmission(redditThings.submission.id));
|
||||
} else {
|
||||
targetResults.push(`[Permalink] Could not parse ${target} as a reddit permalink`);
|
||||
continue;
|
||||
}
|
||||
} catch (err: any) {
|
||||
targetResults.push(`[${targetIdentifier}] error occurred while fetching activity: ${err.message}`);
|
||||
this.logger.warn(new CMError(`[${targetIdentifier}] error occurred while fetching activity`, {cause: err}));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (targetItem.archived) {
|
||||
const archived = `[${targetIdentifier}] Cannot comment because Item is archived`;
|
||||
this.logger.warn(archived);
|
||||
targetResults.push(archived);
|
||||
continue;
|
||||
}
|
||||
|
||||
let modifiers = [];
|
||||
let reply: Comment;
|
||||
if (!dryRun) {
|
||||
// snoopwrap typing issue, thinks comments can't be locked
|
||||
// @ts-ignore
|
||||
await reply.lock();
|
||||
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.distinguish && !dryRun) {
|
||||
modifiers.push('Distinguished');
|
||||
if(this.sticky) {
|
||||
modifiers.push('Stickied');
|
||||
|
||||
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(!dryRun) {
|
||||
// @ts-ignore
|
||||
await reply.distinguish({sticky: this.sticky});
|
||||
|
||||
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
|
||||
targetResults.push(`${targetIdentifier}${modifierStr} created Comment ${dryRun ? 'DRYRUN' : (reply as SnoowrapActivity).name}`)
|
||||
allErrors = false;
|
||||
}
|
||||
|
||||
const modifierStr = modifiers.length === 0 ? '' : `[${modifiers.join(' | ')}]`;
|
||||
|
||||
return {
|
||||
dryRun,
|
||||
success: true,
|
||||
result: `${modifierStr}${truncateStringToLength(100)(body)}`,
|
||||
success: !allErrors,
|
||||
result: `${targetResults.join('\n')}${truncateStringToLength(100)(body)}`,
|
||||
touchedEntities,
|
||||
};
|
||||
}
|
||||
@@ -97,7 +163,8 @@ export class CommentAction extends Action {
|
||||
lock: this.lock,
|
||||
sticky: this.sticky,
|
||||
distinguish: this.distinguish,
|
||||
footer: this.footer
|
||||
footer: this.footer,
|
||||
targets: this.targets,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +182,21 @@ export interface CommentActionConfig extends RequiredRichContent, Footer {
|
||||
* Distinguish the comment after creation?
|
||||
* */
|
||||
distinguish?: boolean,
|
||||
|
||||
/**
|
||||
* Specify where this comment should be made
|
||||
*
|
||||
* Valid values: 'self' | 'parent' | [reddit permalink]
|
||||
*
|
||||
* 'self' and 'parent' are special targets that are relative to the Activity being processed:
|
||||
* * When Activity is Submission => 'parent' does nothing
|
||||
* * When Activity is Comment
|
||||
* * 'self' => reply to Activity
|
||||
* * 'parent' => make a top-level comment in the Submission the Comment is in
|
||||
*
|
||||
* If target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity
|
||||
* */
|
||||
targets?: ArbitraryActionTarget | ArbitraryActionTarget[]
|
||||
}
|
||||
|
||||
export interface CommentActionOptions extends CommentActionConfig, ActionOptions {
|
||||
@@ -124,5 +206,5 @@ export interface CommentActionOptions extends CommentActionConfig, ActionOptions
|
||||
* Reply to the Activity. For a submission the reply will be a top-level comment.
|
||||
* */
|
||||
export interface CommentActionJson extends CommentActionConfig, ActionJson {
|
||||
kind: 'comment'
|
||||
kind: 'comment'
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {activityIsRemoved} from "../Utils/SnoowrapUtils";
|
||||
import {ActionProcessResult, RuleResult} from "../Common/interfaces";
|
||||
import dayjs from "dayjs";
|
||||
import {isSubmission} from "../util";
|
||||
import {isSubmission, truncateStringToLength} from "../util";
|
||||
import {RuleResultEntity} from "../Common/Entities/RuleResultEntity";
|
||||
import {runCheckOptions} from "../Subreddit/Manager";
|
||||
import {ActionTypes} from "../Common/Infrastructure/Atomic";
|
||||
|
||||
const truncate = truncateStringToLength(100);
|
||||
export class RemoveAction extends Action {
|
||||
spam: boolean;
|
||||
note?: string;
|
||||
reasonId?: string;
|
||||
|
||||
getKind(): ActionTypes {
|
||||
return 'remove';
|
||||
@@ -20,21 +23,54 @@ export class RemoveAction extends Action {
|
||||
super(options);
|
||||
const {
|
||||
spam = false,
|
||||
note,
|
||||
reasonId,
|
||||
} = options;
|
||||
this.spam = spam;
|
||||
this.note = note;
|
||||
this.reasonId = reasonId;
|
||||
}
|
||||
|
||||
async process(item: Comment | Submission, ruleResults: RuleResultEntity[], options: runCheckOptions): Promise<ActionProcessResult> {
|
||||
const dryRun = this.getRuntimeAwareDryrun(options);
|
||||
const touchedEntities = [];
|
||||
let removeSummary = [];
|
||||
// issue with snoowrap typings, doesn't think prop exists on Submission
|
||||
// @ts-ignore
|
||||
if (activityIsRemoved(item)) {
|
||||
this.logger.warn('It looks like this Item is already removed!');
|
||||
}
|
||||
if (this.spam) {
|
||||
removeSummary.push('Marked as SPAM');
|
||||
this.logger.verbose('Marking as spam on removal');
|
||||
}
|
||||
const renderedNote = this.note === undefined ? undefined : await this.resources.renderContent(this.note, item, ruleResults);
|
||||
let foundReasonId: string | undefined;
|
||||
let foundReason: string | undefined;
|
||||
|
||||
if(this.reasonId !== undefined) {
|
||||
const reason = await this.resources.getSubredditRemovalReasonById(this.reasonId);
|
||||
if(reason === undefined) {
|
||||
const reasonWarn = [`Could not find any Removal Reason with the ID ${this.reasonId}!`];
|
||||
if(renderedNote === undefined) {
|
||||
reasonWarn.push('Cannot add any Removal Reason because note is also empty!');
|
||||
} else {
|
||||
reasonWarn.push('Will add Removal Reason but only with note.');
|
||||
}
|
||||
this.logger.warn(reasonWarn.join(''));
|
||||
} else {
|
||||
foundReason = truncate(reason.title);
|
||||
foundReasonId = reason.id;
|
||||
removeSummary.push(`Reason: ${truncate(foundReason)} (${foundReasonId})`);
|
||||
}
|
||||
}
|
||||
|
||||
if(renderedNote !== undefined) {
|
||||
removeSummary.push(`Note: ${truncate(renderedNote)}`);
|
||||
}
|
||||
|
||||
this.logger.verbose(removeSummary.join(' | '));
|
||||
|
||||
if (!dryRun) {
|
||||
// @ts-ignore
|
||||
await item.remove({spam: this.spam});
|
||||
@@ -44,6 +80,18 @@ export class RemoveAction extends Action {
|
||||
// @ts-ignore
|
||||
item.removed = true;
|
||||
}
|
||||
|
||||
if(foundReasonId !== undefined || renderedNote !== undefined) {
|
||||
await this.client.addRemovalReason(item, renderedNote, foundReasonId);
|
||||
item.mod_reason_by = this.resources.botAccount as string;
|
||||
if(renderedNote !== undefined) {
|
||||
item.removal_reason = renderedNote;
|
||||
}
|
||||
if(foundReason !== undefined) {
|
||||
item.mod_reason_title = foundReason;
|
||||
}
|
||||
}
|
||||
|
||||
await this.resources.resetCacheForItem(item);
|
||||
touchedEntities.push(item);
|
||||
}
|
||||
@@ -66,7 +114,22 @@ export interface RemoveOptions extends Omit<RemoveActionConfig, 'authorIs' | 'it
|
||||
}
|
||||
|
||||
export interface RemoveActionConfig extends ActionConfig {
|
||||
/** (Optional) Mark Activity as spam */
|
||||
spam?: boolean
|
||||
/** (Optional) A mod-readable note added to the removal reason for this Activity. Can use Templating.
|
||||
*
|
||||
* This note (and removal reasons) are only visible on New Reddit
|
||||
* */
|
||||
note?: string
|
||||
/** (Optional) The ID of the Removal Reason to use
|
||||
*
|
||||
* Removal reasons are only visible on New Reddit
|
||||
*
|
||||
* To find IDs for removal reasons check the "Removal Reasons" popup located in the CM dashboard config editor for your subreddit
|
||||
*
|
||||
* More info on Removal Reasons: https://mods.reddithelp.com/hc/en-us/articles/360010094892-Removal-Reasons
|
||||
* */
|
||||
reasonId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
323
src/Action/SubmissionAction.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
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'
|
||||
}
|
||||
66
src/App.ts
@@ -4,24 +4,27 @@ import {getLogger} from "./Utils/loggerFactory";
|
||||
import {DatabaseMigrationOptions, OperatorConfig, OperatorConfigWithFileContext, OperatorFileConfig} from "./Common/interfaces";
|
||||
import Bot from "./Bot";
|
||||
import LoggedError from "./Utils/LoggedError";
|
||||
import {mergeArr, sleep} from "./util";
|
||||
import {copyFile} from "fs/promises";
|
||||
import {generateRandomName, mergeArr, sleep} from "./util";
|
||||
import {copyFile, open} from "fs/promises";
|
||||
import {constants} from "fs";
|
||||
import {Connection} from "typeorm";
|
||||
import {Connection, DataSource, Repository} from "typeorm";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {MigrationService} from "./Common/MigrationService";
|
||||
import {Invokee} from "./Common/Infrastructure/Atomic";
|
||||
import {DatabaseConfig} from "./Common/Infrastructure/Database";
|
||||
import {InviteData} from "./Web/Common/interfaces";
|
||||
import {BotInvite} from "./Common/Entities/BotInvite";
|
||||
|
||||
export class App {
|
||||
|
||||
bots: Bot[] = [];
|
||||
logger: Logger;
|
||||
dbLogger: Logger;
|
||||
database: Connection
|
||||
database: DataSource
|
||||
startedAt: Dayjs = dayjs();
|
||||
ranMigrations: boolean = false;
|
||||
migrationBlocker?: string;
|
||||
friendly?: string;
|
||||
|
||||
config: OperatorConfig;
|
||||
|
||||
@@ -30,6 +33,7 @@ export class App {
|
||||
fileConfig: OperatorFileConfig;
|
||||
|
||||
migrationService: MigrationService;
|
||||
inviteRepo: Repository<BotInvite>;
|
||||
|
||||
constructor(config: OperatorConfigWithFileContext) {
|
||||
const {
|
||||
@@ -49,6 +53,8 @@ export class App {
|
||||
this.logger = getLogger(config.logging);
|
||||
this.dbLogger = this.logger.child({labels: ['Database']}, mergeArr);
|
||||
this.database = database;
|
||||
this.inviteRepo = this.database.getRepository(BotInvite);
|
||||
this.friendly = this.config.api.friendly;
|
||||
|
||||
this.logger.info(`Operators: ${name.length === 0 ? 'None Specified' : name.join(', ')}`)
|
||||
|
||||
@@ -114,6 +120,8 @@ export class App {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.checkFriendlyName();
|
||||
|
||||
if(this.bots.length > 0) {
|
||||
this.logger.info('Bots already exist, will stop and destroy these before building new ones.');
|
||||
await this.destroy(causedBy);
|
||||
@@ -161,4 +169,54 @@ export class App {
|
||||
await b.destroy(causedBy);
|
||||
}
|
||||
}
|
||||
|
||||
async checkFriendlyName() {
|
||||
if(this.friendly === undefined) {
|
||||
let randFriendly: string = generateRandomName();
|
||||
this.logger.verbose(`No friendly name set for Server. Generated: ${randFriendly}`);
|
||||
|
||||
const exists = async (name: string) => {
|
||||
const existing = await this.inviteRepo.findBy({instance: name});
|
||||
return existing.length > 0;
|
||||
}
|
||||
while (await exists(randFriendly)) {
|
||||
let oldFriendly = randFriendly;
|
||||
randFriendly = generateRandomName();
|
||||
this.logger.verbose(`${oldFriendly} already exists! Generated: ${randFriendly}`);
|
||||
}
|
||||
|
||||
this.friendly = randFriendly;
|
||||
this.fileConfig.document.setFriendlyName(this.friendly);
|
||||
|
||||
const handle = await open(this.fileConfig.document.location as string, 'w');
|
||||
await handle.writeFile(this.fileConfig.document.toString());
|
||||
await handle.close();
|
||||
this.logger.verbose(`Wrote ${randFriendly} as friendly server name to config.`);
|
||||
}
|
||||
}
|
||||
|
||||
async getInviteById(id: string): Promise<BotInvite | undefined> {
|
||||
const invite = await this.inviteRepo.findOne({where: {id, instance: this.friendly}});
|
||||
if(invite === null) {
|
||||
return undefined;
|
||||
}
|
||||
return invite;
|
||||
}
|
||||
|
||||
async getInviteIds(): Promise<string[]> {
|
||||
if(!this.ranMigrations) {
|
||||
// not ready!
|
||||
return [];
|
||||
}
|
||||
const invites = await this.inviteRepo.findBy({instance: this.friendly});
|
||||
return invites.map(x => x.id);
|
||||
}
|
||||
|
||||
async addInvite(data: InviteData): Promise<InviteData> {
|
||||
return await this.inviteRepo.save(new BotInvite(data));
|
||||
}
|
||||
|
||||
async deleteInvite(id: string): Promise<void> {
|
||||
await this.inviteRepo.delete({ id });
|
||||
}
|
||||
}
|
||||
|
||||
148
src/Bot/index.ts
@@ -19,7 +19,7 @@ import {
|
||||
parseBool,
|
||||
parseDuration, parseMatchMessage, parseRedditEntity,
|
||||
parseSubredditName, partition, RetryOptions,
|
||||
sleep
|
||||
sleep, intersect
|
||||
} from "../util";
|
||||
import {Manager} from "../Subreddit/Manager";
|
||||
import {ExtendedSnoowrap, ProxiedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
@@ -43,8 +43,12 @@ import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterSha
|
||||
import {snooLogWrapper} from "../Utils/loggerFactory";
|
||||
import {InfluxClient} from "../Common/Influx/InfluxClient";
|
||||
import {Point} from "@influxdata/influxdb-client";
|
||||
import {BotInstanceFunctions, NormalizedManagerResponse} from "../Web/Common/interfaces";
|
||||
import {AuthorEntity} from "../Common/Entities/AuthorEntity";
|
||||
import {Guest, GuestEntityData} from "../Common/Entities/Guest/GuestInterfaces";
|
||||
import {guestEntitiesToAll, guestEntityToApiGuest} from "../Common/Entities/Guest/GuestEntity";
|
||||
|
||||
class Bot {
|
||||
class Bot implements BotInstanceFunctions {
|
||||
|
||||
client!: ExtendedSnoowrap;
|
||||
logger!: Logger;
|
||||
@@ -99,6 +103,8 @@ class Bot {
|
||||
database: DataSource
|
||||
invokeeRepo: Repository<InvokeeType>;
|
||||
runTypeRepo: Repository<RunStateType>;
|
||||
managerRepo: Repository<ManagerEntity>;
|
||||
authorRepo: Repository<AuthorEntity>;
|
||||
botEntity!: BotEntity
|
||||
|
||||
getBotName = () => {
|
||||
@@ -160,6 +166,8 @@ class Bot {
|
||||
this.database = database;
|
||||
this.invokeeRepo = this.database.getRepository(InvokeeType);
|
||||
this.runTypeRepo = this.database.getRepository(RunStateType);
|
||||
this.managerRepo = this.database.getRepository(ManagerEntity);
|
||||
this.authorRepo = this.database.getRepository(AuthorEntity);
|
||||
this.config = config;
|
||||
this.dryRun = parseBool(dryRun) === true ? true : undefined;
|
||||
this.softLimit = softLimit;
|
||||
@@ -672,15 +680,15 @@ class Bot {
|
||||
databaseConfig: {
|
||||
retention = undefined
|
||||
} = {},
|
||||
wikiConfig = this.wikiLocation,
|
||||
} = override || {};
|
||||
|
||||
const managerRepo = this.database.getRepository(ManagerEntity);
|
||||
const subRepo = this.database.getRepository(SubredditEntity)
|
||||
let subreddit = await subRepo.findOne({where: {id: sub.name}});
|
||||
if(subreddit === null) {
|
||||
subreddit = await subRepo.save(new SubredditEntity({id: sub.name, name: sub.display_name}))
|
||||
}
|
||||
let managerEntity = await managerRepo.findOne({
|
||||
let managerEntity = await this.managerRepo.findOne({
|
||||
where: {
|
||||
bot: {
|
||||
id: this.botEntity.id
|
||||
@@ -689,12 +697,15 @@ class Bot {
|
||||
id: subreddit.id
|
||||
}
|
||||
},
|
||||
relations: {
|
||||
guests: true
|
||||
}
|
||||
});
|
||||
if(managerEntity === undefined || managerEntity === null) {
|
||||
const invokee = await this.invokeeRepo.findOneBy({name: SYSTEM}) as InvokeeType;
|
||||
const runType = await this.runTypeRepo.findOneBy({name: STOPPED}) as RunStateType;
|
||||
|
||||
managerEntity = await managerRepo.save(new ManagerEntity({
|
||||
managerEntity = await this.managerRepo.save(new ManagerEntity({
|
||||
name: sub.display_name,
|
||||
bot: this.botEntity,
|
||||
subreddit: subreddit as SubredditEntity,
|
||||
@@ -707,7 +718,7 @@ class Bot {
|
||||
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {
|
||||
dryRun: this.dryRun,
|
||||
sharedStreams: this.sharedStreams,
|
||||
wikiLocation: this.wikiLocation,
|
||||
wikiLocation: wikiConfig,
|
||||
botName: this.botName as string,
|
||||
maxWorkers: this.maxWorkers,
|
||||
filterCriteriaDefaults: this.filterCriteriaDefaults,
|
||||
@@ -816,6 +827,7 @@ class Bot {
|
||||
await sleep(5000);
|
||||
const time = dayjs().valueOf()
|
||||
await this.apiHealthCheck(time);
|
||||
await this.guestModCleanup();
|
||||
if (!this.running) {
|
||||
break;
|
||||
}
|
||||
@@ -910,6 +922,19 @@ class Bot {
|
||||
|
||||
}
|
||||
|
||||
async guestModCleanup() {
|
||||
const now = dayjs();
|
||||
|
||||
for(const m of this.subManagers) {
|
||||
const expiredGuests = m.managerEntity.getGuests().filter(x => x.expiresAt.isBefore(now));
|
||||
if(expiredGuests.length > 0) {
|
||||
m.managerEntity.removeGuestById(expiredGuests.map(x => x.id));
|
||||
m.logger.info(`Removed expired Guest Mods: ${expiredGuests.map(x => x.author.name).join(', ')}`);
|
||||
await this.managerRepo.save(m.managerEntity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async retentionCleanup() {
|
||||
const now = dayjs();
|
||||
if(now.isSameOrAfter(this.nextRetentionCheck)) {
|
||||
@@ -1100,6 +1125,117 @@ class Bot {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
getManagerNames(): string[] {
|
||||
return this.subManagers.map(x => x.displayLabel);
|
||||
}
|
||||
|
||||
getSubreddits(normalized = true): string[] {
|
||||
return normalized ? this.subManagers.map(x => parseRedditEntity(x.subreddit.display_name).name) : this.subManagers.map(x => x.subreddit.display_name);
|
||||
}
|
||||
|
||||
getGuestManagers(user: string): NormalizedManagerResponse[] {
|
||||
return this.subManagers.filter(x => x.managerEntity.getGuests().map(y => y.author.name).includes(user)).map(x => x.toNormalizedManager());
|
||||
}
|
||||
|
||||
getGuestSubreddits(user: string): string[] {
|
||||
return this.getGuestManagers(user).map(x => x.subredditNormal);
|
||||
}
|
||||
|
||||
getAccessibleSubreddits(user: string, subreddits: string[] = []): string[] {
|
||||
const normalSubs = subreddits.map(x => parseRedditEntity(x).name);
|
||||
const moderatedSubs = intersect(normalSubs, this.getSubreddits());
|
||||
const guestSubs = this.getGuestSubreddits(user);
|
||||
return Array.from(new Set([...guestSubs, ...moderatedSubs]));
|
||||
}
|
||||
|
||||
canUserAccessBot(user: string, subreddits: string[] = []) {
|
||||
return this.getAccessibleSubreddits(user, subreddits).length > 0;
|
||||
}
|
||||
|
||||
canUserAccessSubreddit(subreddit: string, user: string, subreddits: string[] = []): boolean {
|
||||
return this.getAccessibleSubreddits(user, subreddits).includes(parseRedditEntity(subreddit).name);
|
||||
}
|
||||
|
||||
async addGuest(userVal: string | string[], expiresAt: Dayjs, managerVal?: string | string[]) {
|
||||
let managerNames: string[];
|
||||
if(typeof managerVal === 'string') {
|
||||
managerNames = [managerVal];
|
||||
} else if(Array.isArray(managerVal)) {
|
||||
managerNames = managerVal;
|
||||
} else {
|
||||
managerNames = this.subManagers.map(x => x.subreddit.display_name);
|
||||
}
|
||||
|
||||
const cleanSubredditNames = managerNames.map(x => parseRedditEntity(x).name);
|
||||
const userNames = typeof userVal === 'string' ? [userVal] : userVal;
|
||||
const cleanUsers = userNames.map(x => parseRedditEntity(x.trim(), 'user').name);
|
||||
|
||||
const users: AuthorEntity[] = [];
|
||||
|
||||
for(const uName of cleanUsers) {
|
||||
let user = await this.authorRepo.findOne({
|
||||
where: {
|
||||
name: uName,
|
||||
}
|
||||
});
|
||||
|
||||
if(user === null) {
|
||||
users.push(await this.authorRepo.save(new AuthorEntity({name: uName})));
|
||||
} else {
|
||||
users.push(user);
|
||||
}
|
||||
}
|
||||
|
||||
const newGuestData = users.map(x => ({author: x, expiresAt})) as GuestEntityData[];
|
||||
|
||||
let newGuests = new Map<string, Guest[]>();
|
||||
const updatedManagerEntities: ManagerEntity[] = [];
|
||||
for(const m of this.subManagers) {
|
||||
if(!cleanSubredditNames.includes(m.subreddit.display_name)) {
|
||||
continue;
|
||||
}
|
||||
const filteredGuests = m.managerEntity.addGuest(newGuestData);
|
||||
updatedManagerEntities.push(m.managerEntity);
|
||||
newGuests.set(m.displayLabel, filteredGuests.map(x => guestEntityToApiGuest(x)));
|
||||
m.logger.info(`Added ${cleanUsers.join(', ')} as Guest`);
|
||||
}
|
||||
|
||||
await this.managerRepo.save(updatedManagerEntities);
|
||||
|
||||
return newGuests;
|
||||
}
|
||||
|
||||
async removeGuest(userVal: string | string[], managerVal?: string | string[]) {
|
||||
let managerNames: string[];
|
||||
if(typeof managerVal === 'string') {
|
||||
managerNames = [managerVal];
|
||||
} else if(Array.isArray(managerVal)) {
|
||||
managerNames = managerVal;
|
||||
} else {
|
||||
managerNames = this.subManagers.map(x => x.subreddit.display_name);
|
||||
}
|
||||
|
||||
const cleanSubredditNames = managerNames.map(x => parseRedditEntity(x).name);
|
||||
const userNames = typeof userVal === 'string' ? [userVal] : userVal;
|
||||
const cleanUsers = userNames.map(x => parseRedditEntity(x.trim(), 'user').name);
|
||||
|
||||
let newGuests = new Map<string, Guest[]>();
|
||||
const updatedManagerEntities: ManagerEntity[] = [];
|
||||
for(const m of this.subManagers) {
|
||||
if(!cleanSubredditNames.includes(m.subreddit.display_name)) {
|
||||
continue;
|
||||
}
|
||||
const filteredGuests = m.managerEntity.removeGuestByUser(cleanUsers);
|
||||
updatedManagerEntities.push(m.managerEntity);
|
||||
newGuests.set(m.displayLabel, filteredGuests.map(x => guestEntityToApiGuest(x)));
|
||||
m.logger.info(`Removed ${cleanUsers.join(', ')} from Guests`);
|
||||
}
|
||||
|
||||
await this.managerRepo.save(updatedManagerEntities);
|
||||
|
||||
return newGuests;
|
||||
}
|
||||
}
|
||||
|
||||
export default Bot;
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import YamlConfigDocument from "../YamlConfigDocument";
|
||||
import JsonConfigDocument from "../JsonConfigDocument";
|
||||
import {YAMLMap, YAMLSeq, Pair, Scalar} from "yaml";
|
||||
import {BotInstanceJsonConfig, OperatorJsonConfig} from "../../interfaces";
|
||||
import {BotInstanceJsonConfig, OperatorJsonConfig, WebCredentials} from "../../interfaces";
|
||||
import {assign} from 'comment-json';
|
||||
|
||||
export interface OperatorConfigDocumentInterface {
|
||||
addBot(botData: BotInstanceJsonConfig): void;
|
||||
setFriendlyName(name: string): void;
|
||||
setWebCredentials(data: Required<WebCredentials>): void;
|
||||
setOperator(name: string): void;
|
||||
toJS(): OperatorJsonConfig;
|
||||
}
|
||||
|
||||
@@ -29,6 +32,18 @@ export class YamlOperatorConfigDocument extends YamlConfigDocument implements Op
|
||||
}
|
||||
}
|
||||
|
||||
setFriendlyName(name: string) {
|
||||
this.parsed.addIn(['api', 'friendly'], name);
|
||||
}
|
||||
|
||||
setWebCredentials(data: Required<WebCredentials>) {
|
||||
this.parsed.addIn(['web', 'credentials'], data);
|
||||
}
|
||||
|
||||
setOperator(name: string) {
|
||||
this.parsed.addIn(['operator', 'name'], name);
|
||||
}
|
||||
|
||||
toJS(): OperatorJsonConfig {
|
||||
return super.toJS();
|
||||
}
|
||||
@@ -68,6 +83,23 @@ export class JsonOperatorConfigDocument extends JsonConfigDocument implements Op
|
||||
}
|
||||
}
|
||||
|
||||
setFriendlyName(name: string) {
|
||||
const api = this.parsed.api || {};
|
||||
this.parsed.api = {...api, friendly: name};
|
||||
}
|
||||
|
||||
setWebCredentials(data: Required<WebCredentials>) {
|
||||
const {
|
||||
web = {},
|
||||
} = this.parsed;
|
||||
|
||||
this.parsed.web = {...web, credentials: data};
|
||||
}
|
||||
|
||||
setOperator(name: string) {
|
||||
this.parsed.operator = { name };
|
||||
}
|
||||
|
||||
toJS(): OperatorJsonConfig {
|
||||
return super.toJS();
|
||||
}
|
||||
|
||||
@@ -12,4 +12,10 @@ export class AuthorEntity {
|
||||
|
||||
@OneToMany(type => Activity, act => act.author)
|
||||
activities!: Activity[]
|
||||
|
||||
constructor(data?: any) {
|
||||
if(data !== undefined) {
|
||||
this.name = data.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,68 @@
|
||||
import {Entity, Column, PrimaryColumn, OneToMany, PrimaryGeneratedColumn} from "typeorm";
|
||||
import {ManagerEntity} from "./ManagerEntity";
|
||||
import {RandomIdBaseEntity} from "./Base/RandomIdBaseEntity";
|
||||
import {BotGuestEntity, ManagerGuestEntity} from "./Guest/GuestEntity";
|
||||
import {Guest, GuestEntityData, HasGuests} from "./Guest/GuestInterfaces";
|
||||
import {SubredditInvite} from "./SubredditInvite";
|
||||
|
||||
@Entity()
|
||||
export class Bot extends RandomIdBaseEntity {
|
||||
export class Bot extends RandomIdBaseEntity implements HasGuests {
|
||||
|
||||
@Column("varchar", {length: 200})
|
||||
name!: string;
|
||||
|
||||
@OneToMany(type => ManagerEntity, obj => obj.bot)
|
||||
managers!: Promise<ManagerEntity[]>
|
||||
|
||||
@OneToMany(type => BotGuestEntity, obj => obj.guestOf, {eager: true, cascade: ['insert', 'remove', 'update']})
|
||||
guests!: BotGuestEntity[]
|
||||
|
||||
@OneToMany(type => SubredditInvite, obj => obj.bot, {eager: true, cascade: ['insert', 'remove', 'update']})
|
||||
subredditInvites!: SubredditInvite[]
|
||||
|
||||
getGuests() {
|
||||
const g = this.guests;
|
||||
if (g === undefined) {
|
||||
return [];
|
||||
}
|
||||
//return g.map(x => ({id: x.id, name: x.author.name, expiresAt: x.expiresAt})) as Guest[];
|
||||
return g;
|
||||
}
|
||||
|
||||
addGuest(val: GuestEntityData | GuestEntityData[]) {
|
||||
const reqGuests = Array.isArray(val) ? val : [val];
|
||||
const guests = this.guests;
|
||||
for (const g of reqGuests) {
|
||||
const existing = guests.find(x => x.author.name.toLowerCase() === g.author.name.toLowerCase());
|
||||
if (existing !== undefined) {
|
||||
// update existing guest expiresAt
|
||||
existing.expiresAt = g.expiresAt;
|
||||
} else {
|
||||
guests.push(new BotGuestEntity({...g, guestOf: this}));
|
||||
}
|
||||
}
|
||||
this.guests = guests
|
||||
return guests;
|
||||
}
|
||||
|
||||
removeGuestById(val: string | string[]) {
|
||||
const reqGuests = Array.isArray(val) ? val : [val];
|
||||
const guests = this.guests;
|
||||
const filteredGuests = guests.filter(x => reqGuests.includes(x.id));
|
||||
this.guests = filteredGuests;
|
||||
return filteredGuests;
|
||||
}
|
||||
|
||||
removeGuestByUser(val: string | string[]) {
|
||||
const reqGuests = (Array.isArray(val) ? val : [val]).map(x => x.trim().toLowerCase());
|
||||
const guests = this.guests;
|
||||
const filteredGuests = guests.filter(x => reqGuests.includes(x.author.name.toLowerCase()));
|
||||
this.guests =filteredGuests;
|
||||
return filteredGuests;
|
||||
}
|
||||
|
||||
removeGuests() {
|
||||
this.guests = []
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {Column, Entity, PrimaryColumn} from "typeorm";
|
||||
import {TimeAwareBaseEntity} from "../Entities/Base/TimeAwareBaseEntity";
|
||||
import {TimeAwareBaseEntity} from "./Base/TimeAwareBaseEntity";
|
||||
import {InviteData} from "../../Web/Common/interfaces";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {TimeAwareRandomBaseEntity} from "./Base/TimeAwareRandomBaseEntity";
|
||||
import {parseRedditEntity} from "../../util";
|
||||
|
||||
@Entity()
|
||||
export class Invite extends TimeAwareBaseEntity implements InviteData {
|
||||
|
||||
@PrimaryColumn('varchar', {length: 255})
|
||||
id!: string
|
||||
@Entity({name: 'BotInvite'})
|
||||
export class BotInvite extends TimeAwareRandomBaseEntity implements InviteData {
|
||||
|
||||
@Column("varchar", {length: 50})
|
||||
clientId!: string;
|
||||
@@ -30,6 +29,12 @@ export class Invite extends TimeAwareBaseEntity implements InviteData {
|
||||
@Column()
|
||||
overwrite?: boolean;
|
||||
|
||||
@Column("simple-json")
|
||||
guests?: string[]
|
||||
|
||||
@Column("text")
|
||||
initialConfig?: string
|
||||
|
||||
@Column("simple-json", {nullable: true})
|
||||
subreddits?: string[];
|
||||
|
||||
@@ -51,10 +56,9 @@ export class Invite extends TimeAwareBaseEntity implements InviteData {
|
||||
}
|
||||
}
|
||||
|
||||
constructor(data?: InviteData & { id: string, expiresIn?: number }) {
|
||||
constructor(data?: InviteData) {
|
||||
super();
|
||||
if (data !== undefined) {
|
||||
this.id = data.id;
|
||||
this.permissions = data.permissions;
|
||||
this.subreddits = data.subreddits;
|
||||
this.instance = data.instance;
|
||||
@@ -63,9 +67,16 @@ export class Invite extends TimeAwareBaseEntity implements InviteData {
|
||||
this.redirectUri = data.redirectUri;
|
||||
this.creator = data.creator;
|
||||
this.overwrite = data.overwrite;
|
||||
this.initialConfig = data.initialConfig;
|
||||
if(data.guests !== undefined && data.guests !== null && data.guests.length > 0) {
|
||||
const cleanGuests = data.guests.filter(x => x !== '').map(x => parseRedditEntity(x, 'user').name);
|
||||
if(cleanGuests.length > 0) {
|
||||
this.guests = cleanGuests;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.expiresIn !== undefined && data.expiresIn !== 0) {
|
||||
this.expiresAt = dayjs().add(data.expiresIn, 'seconds');
|
||||
if (data.expiresAt !== undefined && data.expiresAt !== 0) {
|
||||
this.expiresAt = dayjs(data.expiresAt);
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/Common/Entities/Guest/GuestEntity.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import {ChildEntity, Column, Entity, JoinColumn, ManyToOne, TableInheritance} from "typeorm";
|
||||
import {AuthorEntity} from "../AuthorEntity";
|
||||
import { ManagerEntity } from "../ManagerEntity";
|
||||
import { Bot } from "../Bot";
|
||||
import {TimeAwareRandomBaseEntity} from "../Base/TimeAwareRandomBaseEntity";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {Guest, GuestAll, GuestEntityData} from "./GuestInterfaces";
|
||||
|
||||
export interface GuestOptions<T extends ManagerEntity | Bot> extends GuestEntityData {
|
||||
guestOf: T
|
||||
}
|
||||
|
||||
@Entity({name: 'Guests'})
|
||||
@TableInheritance({ column: { type: "varchar", name: "type" } })
|
||||
export abstract class GuestEntity<T extends ManagerEntity | Bot> extends TimeAwareRandomBaseEntity {
|
||||
|
||||
@ManyToOne(type => AuthorEntity, undefined, {cascade: ['insert'], eager: true})
|
||||
@JoinColumn({name: 'authorName'})
|
||||
author!: AuthorEntity;
|
||||
|
||||
@Column({ name: 'expiresAt', nullable: true })
|
||||
_expiresAt?: Date = new Date();
|
||||
|
||||
public get expiresAt(): Dayjs {
|
||||
return dayjs(this._expiresAt);
|
||||
}
|
||||
|
||||
public set expiresAt(d: Dayjs | undefined) {
|
||||
if(d === undefined) {
|
||||
this._expiresAt = d;
|
||||
} else {
|
||||
this._expiresAt = d.utc().toDate();
|
||||
}
|
||||
}
|
||||
|
||||
expiresAtTimestamp(): number | undefined {
|
||||
if(this._expiresAt !== undefined) {
|
||||
return this.expiresAt.valueOf();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected constructor(data?: GuestOptions<T>) {
|
||||
super();
|
||||
if(data !== undefined) {
|
||||
this.author = data.author;
|
||||
this.expiresAt = data.expiresAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ChildEntity('manager')
|
||||
export class ManagerGuestEntity extends GuestEntity<ManagerEntity> {
|
||||
|
||||
type: string = 'manager';
|
||||
|
||||
@ManyToOne(type => ManagerEntity, act => act.guests, {nullable: false, orphanedRowAction: 'delete'})
|
||||
@JoinColumn({name: 'guestOfId', referencedColumnName: 'id'})
|
||||
guestOf!: ManagerEntity
|
||||
|
||||
constructor(data?: GuestOptions<ManagerEntity>) {
|
||||
super(data);
|
||||
if(data !== undefined) {
|
||||
this.guestOf = data.guestOf;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ChildEntity('bot')
|
||||
export class BotGuestEntity extends GuestEntity<Bot> {
|
||||
|
||||
type: string = 'bot';
|
||||
|
||||
@ManyToOne(type => Bot, act => act.guests, {nullable: false, orphanedRowAction: 'delete'})
|
||||
@JoinColumn({name: 'guestOfId', referencedColumnName: 'id'})
|
||||
guestOf!: Bot
|
||||
|
||||
constructor(data?: GuestOptions<Bot>) {
|
||||
super(data);
|
||||
if(data !== undefined) {
|
||||
this.guestOf = data.guestOf;
|
||||
this.author = data.author;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const guestEntityToApiGuest = (val: GuestEntity<any>): Guest => {
|
||||
return {
|
||||
id: val.id,
|
||||
name: val.author.name,
|
||||
expiresAt: val.expiresAtTimestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
interface ContextualGuest extends Guest {
|
||||
subreddit: string
|
||||
}
|
||||
|
||||
export const guestEntitiesToAll = (val: Map<string, Guest[]>): GuestAll[] => {
|
||||
const contextualGuests: ContextualGuest[] = Array.from(val.entries()).map(([sub, guests]) => guests.map(y => ({...y, subreddit: sub} as ContextualGuest))).flat(3);
|
||||
|
||||
const userMap = contextualGuests.reduce((acc, curr) => {
|
||||
let u: GuestAll | undefined = acc.get(curr.name);
|
||||
if (u === undefined) {
|
||||
u = {name: curr.name, expiresAt: curr.expiresAt, subreddits: [curr.subreddit]};
|
||||
} else {
|
||||
if (!u.subreddits.includes(curr.subreddit)) {
|
||||
u.subreddits.push(curr.subreddit);
|
||||
}
|
||||
if ((u.expiresAt === undefined && curr.expiresAt !== undefined) || (u.expiresAt !== undefined && curr.expiresAt !== undefined && curr.expiresAt < u.expiresAt)) {
|
||||
u.expiresAt = curr.expiresAt;
|
||||
}
|
||||
}
|
||||
acc.set(curr.name, u);
|
||||
return acc;
|
||||
}, new Map<string, GuestAll>());
|
||||
|
||||
return Array.from(userMap.values());
|
||||
}
|
||||
28
src/Common/Entities/Guest/GuestInterfaces.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Dayjs } from "dayjs"
|
||||
import {AuthorEntity} from "../AuthorEntity";
|
||||
|
||||
export interface Guest {
|
||||
id: string
|
||||
name: string
|
||||
expiresAt?: number
|
||||
}
|
||||
|
||||
export interface GuestAll {
|
||||
name: string
|
||||
expiresAt?: number
|
||||
subreddits: string[]
|
||||
}
|
||||
|
||||
|
||||
export interface GuestEntityData {
|
||||
expiresAt?: Dayjs
|
||||
author: AuthorEntity
|
||||
}
|
||||
|
||||
export interface HasGuests {
|
||||
getGuests: () => GuestEntityData[]
|
||||
addGuest: (val: GuestEntityData | GuestEntityData[]) => GuestEntityData[]
|
||||
removeGuestById: (val: string | string[]) => GuestEntityData[]
|
||||
removeGuestByUser: (val: string | string[]) => GuestEntityData[]
|
||||
removeGuests: () => GuestEntityData[]
|
||||
}
|
||||
@@ -15,12 +15,14 @@ import {RunEntity} from "./RunEntity";
|
||||
import {Bot} from "./Bot";
|
||||
import {RandomIdBaseEntity} from "./Base/RandomIdBaseEntity";
|
||||
import {ManagerRunState} from "./EntityRunState/ManagerRunState";
|
||||
import { QueueRunState } from "./EntityRunState/QueueRunState";
|
||||
import {QueueRunState} from "./EntityRunState/QueueRunState";
|
||||
import {EventsRunState} from "./EntityRunState/EventsRunState";
|
||||
import {RulePremise} from "./RulePremise";
|
||||
import {ActionPremise} from "./ActionPremise";
|
||||
import { RunningStateTypes } from "../../Subreddit/Manager";
|
||||
import {RunningStateTypes} from "../../Subreddit/Manager";
|
||||
import {EntityRunState} from "./EntityRunState/EntityRunState";
|
||||
import {GuestEntity, ManagerGuestEntity} from "./Guest/GuestEntity";
|
||||
import {Guest, GuestEntityData, HasGuests} from "./Guest/GuestInterfaces";
|
||||
|
||||
export interface ManagerEntityOptions {
|
||||
name: string
|
||||
@@ -36,7 +38,7 @@ export type RunningStateEntities = {
|
||||
};
|
||||
|
||||
@Entity({name: 'Manager'})
|
||||
export class ManagerEntity extends RandomIdBaseEntity implements RunningStateEntities {
|
||||
export class ManagerEntity extends RandomIdBaseEntity implements RunningStateEntities, HasGuests {
|
||||
|
||||
@Column("varchar", {length: 200})
|
||||
name!: string;
|
||||
@@ -56,12 +58,15 @@ export class ManagerEntity extends RandomIdBaseEntity implements RunningStateEnt
|
||||
@OneToMany(type => ActionPremise, obj => obj.manager)
|
||||
actions!: Promise<ActionPremise[]>
|
||||
|
||||
@OneToMany(type => CheckEntity, obj => obj.manager) // note: we will create author property in the Photo class below
|
||||
@OneToMany(type => CheckEntity, obj => obj.manager)
|
||||
checks!: Promise<CheckEntity[]>
|
||||
|
||||
@OneToMany(type => RunEntity, obj => obj.manager) // note: we will create author property in the Photo class below
|
||||
@OneToMany(type => RunEntity, obj => obj.manager)
|
||||
runs!: Promise<RunEntity[]>
|
||||
|
||||
@OneToMany(type => ManagerGuestEntity, obj => obj.guestOf, {eager: true, cascade: ['insert', 'remove', 'update']})
|
||||
guests!: ManagerGuestEntity[]
|
||||
|
||||
@OneToOne(() => EventsRunState, {cascade: ['insert', 'update'], eager: true})
|
||||
@JoinColumn()
|
||||
eventsState!: EventsRunState
|
||||
@@ -85,4 +90,50 @@ export class ManagerEntity extends RandomIdBaseEntity implements RunningStateEnt
|
||||
this.managerState = data.managerState;
|
||||
}
|
||||
}
|
||||
|
||||
getGuests(): ManagerGuestEntity[] {
|
||||
const g = this.guests;
|
||||
if (g === undefined) {
|
||||
return [];
|
||||
}
|
||||
//return g.map(x => ({id: x.id, name: x.author.name, expiresAt: x.expiresAt})) as Guest[];
|
||||
return g;
|
||||
}
|
||||
|
||||
addGuest(val: GuestEntityData | GuestEntityData[]) {
|
||||
const reqGuests = Array.isArray(val) ? val : [val];
|
||||
const guests = this.getGuests();
|
||||
for (const g of reqGuests) {
|
||||
const existing = guests.find(x => x.author.name.toLowerCase() === g.author.name.toLowerCase());
|
||||
if (existing !== undefined) {
|
||||
// update existing guest expiresAt
|
||||
existing.expiresAt = g.expiresAt;
|
||||
} else {
|
||||
guests.push(new ManagerGuestEntity({...g, guestOf: this}));
|
||||
}
|
||||
}
|
||||
this.guests = guests;
|
||||
return guests;
|
||||
}
|
||||
|
||||
removeGuestById(val: string | string[]) {
|
||||
const reqGuests = Array.isArray(val) ? val : [val];
|
||||
const guests = this.getGuests();
|
||||
const filteredGuests = guests.filter(x => !reqGuests.includes(x.id));
|
||||
this.guests = filteredGuests
|
||||
return filteredGuests;
|
||||
}
|
||||
|
||||
removeGuestByUser(val: string | string[]) {
|
||||
const reqGuests = (Array.isArray(val) ? val : [val]).map(x => x.trim().toLowerCase());
|
||||
const guests = this.getGuests();
|
||||
const filteredGuests = guests.filter(x => !reqGuests.includes(x.author.name.toLowerCase()));
|
||||
this.guests = filteredGuests;
|
||||
return filteredGuests;
|
||||
}
|
||||
|
||||
removeGuests() {
|
||||
this.guests = [];
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
54
src/Common/Entities/SubredditInvite.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {Column, Entity, JoinColumn, ManyToOne, PrimaryColumn} from "typeorm";
|
||||
import {InviteData, SubredditInviteData} from "../../Web/Common/interfaces";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {TimeAwareRandomBaseEntity} from "./Base/TimeAwareRandomBaseEntity";
|
||||
import {AuthorEntity} from "./AuthorEntity";
|
||||
import {Bot} from "./Bot";
|
||||
|
||||
@Entity()
|
||||
export class SubredditInvite extends TimeAwareRandomBaseEntity implements SubredditInviteData {
|
||||
|
||||
subreddit!: string;
|
||||
|
||||
@Column("simple-json", {nullable: true})
|
||||
guests?: string[]
|
||||
|
||||
@Column("text")
|
||||
initialConfig?: string
|
||||
|
||||
@ManyToOne(type => Bot, bot => bot.subredditInvites, {nullable: false, orphanedRowAction: 'delete'})
|
||||
@JoinColumn({name: 'botId', referencedColumnName: 'id'})
|
||||
bot!: Bot;
|
||||
|
||||
@Column({name: 'expiresAt', nullable: true})
|
||||
_expiresAt?: Date;
|
||||
|
||||
public get expiresAt(): Dayjs | undefined {
|
||||
if (this._expiresAt === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return dayjs(this._expiresAt);
|
||||
}
|
||||
|
||||
public set expiresAt(d: Dayjs | undefined) {
|
||||
if (d === undefined) {
|
||||
this._expiresAt = d;
|
||||
} else {
|
||||
this._expiresAt = d.utc().toDate();
|
||||
}
|
||||
}
|
||||
|
||||
constructor(data?: SubredditInviteData & { expiresIn?: number }) {
|
||||
super();
|
||||
if (data !== undefined) {
|
||||
this.subreddit = data.subreddit;
|
||||
this.initialConfig = data.initialConfig;
|
||||
this.guests = data.guests;
|
||||
|
||||
|
||||
if (data.expiresIn !== undefined && data.expiresIn !== 0) {
|
||||
this.expiresAt = dayjs().add(data.expiresIn, 'seconds');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,17 @@
|
||||
import fetch from "node-fetch";
|
||||
import {Submission} from "snoowrap/dist/objects";
|
||||
import {URL} from "url";
|
||||
import {absPercentDifference, getSharpAsync, isValidImageURL} from "../util";
|
||||
import {absPercentDifference, getExtension, getSharpAsync, isValidImageURL} from "../util";
|
||||
import {Sharp} from "sharp";
|
||||
import {blockhash} from "./blockhash/blockhash";
|
||||
import {SimpleError} from "../Utils/Errors";
|
||||
import {blockhashAndFlipped} from "./blockhash/blockhash";
|
||||
import {CMError, SimpleError} from "../Utils/Errors";
|
||||
import {FileHandle, open} from "fs/promises";
|
||||
import {ImageHashCacheData} from "./Infrastructure/Atomic";
|
||||
|
||||
export interface ImageDataOptions {
|
||||
width?: number,
|
||||
height?: number,
|
||||
url: string,
|
||||
path: URL,
|
||||
variants?: ImageData[]
|
||||
}
|
||||
|
||||
@@ -17,19 +19,20 @@ class ImageData {
|
||||
|
||||
width?: number
|
||||
height?: number
|
||||
url: URL
|
||||
path: URL
|
||||
variants: ImageData[] = []
|
||||
preferredResolution?: [number, number]
|
||||
sharpImg!: Sharp
|
||||
hashResult!: string
|
||||
hashResult?: string
|
||||
hashResultFlipped?: string
|
||||
actualResolution?: [number, number]
|
||||
|
||||
constructor(data: ImageDataOptions, aggressive = false) {
|
||||
this.width = data.width;
|
||||
this.height = data.height;
|
||||
this.url = new URL(data.url);
|
||||
if (!aggressive && !isValidImageURL(`${this.url.origin}${this.url.pathname}`)) {
|
||||
throw new Error('URL did not end with a valid image extension');
|
||||
this.path = data.path;
|
||||
if (!aggressive && !isValidImageURL(`${this.path.origin}${this.path.pathname}`)) {
|
||||
throw new Error('Path did not end with a valid image extension');
|
||||
}
|
||||
this.variants = data.variants || [];
|
||||
}
|
||||
@@ -39,55 +42,90 @@ class ImageData {
|
||||
return await (await this.sharp()).clone().toFormat(format).toBuffer();
|
||||
}
|
||||
|
||||
async hash(bits: number, useVariantIfPossible = true): Promise<string> {
|
||||
if(this.hashResult === undefined) {
|
||||
async hash(bits: number = 16, useVariantIfPossible = true): Promise<Required<ImageHashCacheData>> {
|
||||
if (this.hashResult === undefined || this.hashResultFlipped === undefined) {
|
||||
let ref: ImageData | undefined;
|
||||
if(useVariantIfPossible && this.preferredResolution !== undefined) {
|
||||
if (useVariantIfPossible && this.preferredResolution !== undefined) {
|
||||
ref = this.getSimilarResolutionVariant(this.preferredResolution[0], this.preferredResolution[1]);
|
||||
}
|
||||
if(ref === undefined) {
|
||||
if (ref === undefined) {
|
||||
ref = this;
|
||||
}
|
||||
this.hashResult = await blockhash((await ref.sharp()).clone(), bits);
|
||||
const [hash, hashFlipped] = await blockhashAndFlipped((await ref.sharp()).clone(), bits);
|
||||
this.hashResult = hash;
|
||||
this.hashResultFlipped = hashFlipped;
|
||||
}
|
||||
return this.hashResult;
|
||||
return {original: this.hashResult, flipped: this.hashResultFlipped};
|
||||
}
|
||||
|
||||
async sharp(): Promise<Sharp> {
|
||||
if (this.sharpImg === undefined) {
|
||||
let animated = false;
|
||||
let getBuffer: () => Promise<Buffer>;
|
||||
let fileHandle: FileHandle | undefined;
|
||||
try {
|
||||
const response = await fetch(this.url.toString())
|
||||
if (response.ok) {
|
||||
const ct = response.headers.get('Content-Type');
|
||||
if (ct !== null && ct.includes('image')) {
|
||||
const sFunc = await getSharpAsync();
|
||||
// if image is animated then we want to extract the first frame and convert it to a regular image
|
||||
// so we can compare two static images later (also because sharp can't use resize() on animated images)
|
||||
if(['gif','webp'].some(x => ct.includes(x))) {
|
||||
this.sharpImg = await sFunc(await (await sFunc(await response.buffer(), {pages: 1, animated: false})).png().toBuffer());
|
||||
} else {
|
||||
this.sharpImg = await sFunc(await response.buffer());
|
||||
}
|
||||
const meta = await this.sharpImg.metadata();
|
||||
if (this.width === undefined || this.height === undefined) {
|
||||
this.width = meta.width;
|
||||
this.height = meta.height;
|
||||
}
|
||||
this.actualResolution = [meta.width as number, meta.height as number];
|
||||
} else {
|
||||
throw new SimpleError(`Content-Type for fetched URL ${this.url} did not contain "image"`);
|
||||
if (this.path.protocol === 'file:') {
|
||||
try {
|
||||
animated = ['gif', 'webp'].includes(getExtension(this.path.pathname));
|
||||
fileHandle = await open(this.path, 'r');
|
||||
getBuffer = async () => await (fileHandle as FileHandle).readFile();
|
||||
} catch (err: any) {
|
||||
throw new CMError(`Unable to retrieve local file ${this.path.toString()}`, {cause: err});
|
||||
}
|
||||
} else {
|
||||
throw new SimpleError(`URL response was not OK: (${response.status})${response.statusText}`);
|
||||
try {
|
||||
const response = await fetch(this.path.toString())
|
||||
if (response.ok) {
|
||||
const ct = response.headers.get('Content-Type');
|
||||
if (ct !== null && ct.includes('image')) {
|
||||
animated = ['gif', 'webp'].some(x => ct.includes(x));
|
||||
getBuffer = async () => await response.buffer();
|
||||
} else {
|
||||
throw new SimpleError(`Content-Type for fetched URL ${this.path.toString()} did not contain "image"`);
|
||||
}
|
||||
} else {
|
||||
throw new SimpleError(`Fetching ${this.path.toString()} => URL response was not OK: (${response.status})${response.statusText}`);
|
||||
}
|
||||
|
||||
} catch (err: any) {
|
||||
if (!(err instanceof SimpleError)) {
|
||||
throw new CMError(`Error occurred while fetching response from URL ${this.path.toString()}`, {cause: err});
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
throw new CMError('Unable to fetch image resource', {cause: err, isSerious: false});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
const sFunc = await getSharpAsync();
|
||||
// if image is animated then we want to extract the first frame and convert it to a regular image
|
||||
// so we can compare two static images later (also because sharp can't use resize() on animated images)
|
||||
if (animated) {
|
||||
this.sharpImg = await sFunc(await (await sFunc(await getBuffer(), {
|
||||
pages: 1,
|
||||
animated: false
|
||||
}).trim().greyscale()).png().withMetadata().toBuffer());
|
||||
} else {
|
||||
this.sharpImg = await sFunc(await sFunc(await getBuffer()).trim().greyscale().withMetadata().toBuffer());
|
||||
}
|
||||
|
||||
if(fileHandle !== undefined) {
|
||||
await fileHandle.close();
|
||||
}
|
||||
|
||||
const meta = await this.sharpImg.metadata();
|
||||
if (this.width === undefined || this.height === undefined) {
|
||||
this.width = meta.width;
|
||||
this.height = meta.height;
|
||||
}
|
||||
this.actualResolution = [meta.width as number, meta.height as number];
|
||||
|
||||
} catch (err: any) {
|
||||
if(!(err instanceof SimpleError)) {
|
||||
throw new Error(`Error occurred while fetching response from URL: ${err.message}`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
throw new CMError('Error occurred while converting image buffer to Sharp object', {cause: err});
|
||||
}
|
||||
}
|
||||
return this.sharpImg;
|
||||
@@ -107,8 +145,8 @@ class ImageData {
|
||||
return this.width !== undefined && this.height !== undefined;
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return `${this.url.origin}${this.url.pathname}`;
|
||||
get basePath() {
|
||||
return `${this.path.origin}${this.path.pathname}`;
|
||||
}
|
||||
|
||||
setPreferredResolutionByWidth(prefWidth: number) {
|
||||
@@ -225,10 +263,23 @@ class ImageData {
|
||||
return [refSharp, compareSharp, width, height];
|
||||
}
|
||||
|
||||
toHashCache(): ImageHashCacheData {
|
||||
return {
|
||||
original: this.hashResult,
|
||||
flipped: this.hashResultFlipped
|
||||
}
|
||||
}
|
||||
|
||||
setFromHashCache(data: ImageHashCacheData) {
|
||||
const {original, flipped} = data;
|
||||
this.hashResult = original;
|
||||
this.hashResultFlipped = flipped;
|
||||
}
|
||||
|
||||
static fromSubmission(sub: Submission, aggressive = false): ImageData {
|
||||
const url = new URL(sub.url);
|
||||
const data: any = {
|
||||
url,
|
||||
path: url,
|
||||
};
|
||||
let variants = [];
|
||||
if (sub.preview !== undefined && sub.preview.enabled && sub.preview.images.length > 0) {
|
||||
@@ -237,7 +288,7 @@ class ImageData {
|
||||
data.width = ref.width;
|
||||
data.height = ref.height;
|
||||
|
||||
variants = firstImg.resolutions.map(x => new ImageData(x));
|
||||
variants = firstImg.resolutions.map(x => new ImageData({...x, path: new URL(x.url)}));
|
||||
data.variants = variants;
|
||||
}
|
||||
return new ImageData(data, aggressive);
|
||||
|
||||
@@ -148,6 +148,7 @@ export type RecordOutputOption = boolean | RecordOutputType | RecordOutputType[]
|
||||
export type PostBehaviorType = 'next' | 'stop' | 'nextRun' | string;
|
||||
export type onExistingFoundBehavior = 'replace' | 'skip' | 'ignore';
|
||||
export type ActionTarget = 'self' | 'parent';
|
||||
export type ArbitraryActionTarget = ActionTarget | string;
|
||||
export type InclusiveActionTarget = ActionTarget | 'any';
|
||||
export type DispatchSource = 'dispatch' | `dispatch:${string}`;
|
||||
export type NonDispatchActivitySource = 'poll' | `poll:${PollOn}` | 'user' | `user:${string}`;
|
||||
@@ -174,6 +175,7 @@ export type ActivitySource = NonDispatchActivitySource | DispatchSource;
|
||||
export type ConfigFormat = 'json' | 'yaml';
|
||||
export type ActionTypes =
|
||||
'comment'
|
||||
| 'submission'
|
||||
| 'lock'
|
||||
| 'remove'
|
||||
| 'report'
|
||||
@@ -277,3 +279,8 @@ export interface UrlContext {
|
||||
value: string
|
||||
context: WikiContext | ExternalUrlContext
|
||||
}
|
||||
|
||||
export interface ImageHashCacheData {
|
||||
original?: string
|
||||
flipped?: string
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ export interface ActivityState {
|
||||
dispatched?: boolean | string | string[]
|
||||
|
||||
|
||||
// can use ActivitySource | ActivitySource[] here because of issues with generating json schema, see ActivitySource comments
|
||||
// cant use ActivitySource | ActivitySource[] here because of issues with generating json schema, see ActivitySource comments
|
||||
/**
|
||||
* Test where the current activity was sourced from.
|
||||
*
|
||||
|
||||
@@ -75,3 +75,16 @@ export const activityReports = (activity: SnoowrapActivity): Report[] => {
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
export interface RawSubredditRemovalReasonData {
|
||||
data: {
|
||||
[key: string]: SubredditRemovalReason
|
||||
},
|
||||
order: [string]
|
||||
}
|
||||
|
||||
export interface SubredditRemovalReason {
|
||||
message: string
|
||||
id: string,
|
||||
title: string
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {DatabaseMigrationOptions} from "./interfaces";
|
||||
import {copyFile} from "fs/promises";
|
||||
import {constants} from "fs";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import {CMError} from "../Utils/Errors";
|
||||
|
||||
export interface ExistingTable {
|
||||
table: Table
|
||||
@@ -118,9 +119,10 @@ export class MigrationService {
|
||||
try {
|
||||
await this.backupDatabase();
|
||||
continueBCBackedup = true;
|
||||
} catch (err) {
|
||||
// @ts-ignore
|
||||
this.dbLogger.error(err, {leaf: 'Backup'});
|
||||
} catch (err: any) {
|
||||
if(!(err instanceof CMError) || !err.logged) {
|
||||
this.dbLogger.error(err, {leaf: 'Backup'});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.dbLogger.info('Configuration DID NOT specify migrations may be executed if automated backup is successful. Will not try to create a backup.');
|
||||
@@ -154,25 +156,34 @@ YOU SHOULD BACKUP YOUR EXISTING DATABASE BEFORE CONTINUING WITH MIGRATIONS.`);
|
||||
|
||||
async backupDatabase() {
|
||||
try {
|
||||
if (this.database.options.type === 'sqljs' && this.database.options.location !== undefined) {
|
||||
let location: string | undefined;
|
||||
const canBackup = ['sqljs','better-sqlite3'].includes(this.database.options.type);
|
||||
if(canBackup) {
|
||||
if(this.database.options.type === 'sqljs') {
|
||||
location = this.database.options.location === ':memory:' ? undefined : this.database.options.location;
|
||||
} else {
|
||||
location = this.database.options.database === ':memory:' || (typeof this.database.options.database !== 'string') ? undefined : this.database.options.database;
|
||||
}
|
||||
}
|
||||
if (canBackup && location !== undefined) {
|
||||
try {
|
||||
const ts = Date.now();
|
||||
const backupLocation = `${this.database.options.location}.${ts}.bak`
|
||||
const backupLocation = `${location}.${ts}.bak`
|
||||
this.dbLogger.info(`Detected sqljs (sqlite) database. Will try to make a backup at ${backupLocation}`, {leaf: 'Backup'});
|
||||
await copyFile(this.database.options.location, backupLocation, constants.COPYFILE_EXCL);
|
||||
await copyFile(location, backupLocation, constants.COPYFILE_EXCL);
|
||||
this.dbLogger.info('Successfully created backup!', {leaf: 'Backup'});
|
||||
} catch (err: any) {
|
||||
throw new ErrorWithCause('Cannot make an automated backup of your configured database.', {cause: err});
|
||||
}
|
||||
} else {
|
||||
let msg = 'Cannot make an automated backup of your configured database.';
|
||||
if (this.database.options.type !== 'sqljs') {
|
||||
msg += ' Only SQlite (sqljs database type) is implemented for automated backups right now, sorry :( You will need to manually backup your database.';
|
||||
if (!canBackup) {
|
||||
msg += ' Only SQlite (sqljs or better-sqlite3 database type) is implemented for automated backups right now, sorry :( You will need to manually backup your database.';
|
||||
} else {
|
||||
// TODO don't throw for this??
|
||||
msg += ' Database location is not defined (probably in-memory).';
|
||||
}
|
||||
throw new Error(msg);
|
||||
throw new CMError(msg, {logged: true});
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.dbLogger.error(e, {leaf: 'Backup'});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {TableIndex} from "typeorm";
|
||||
import {QueryRunner, TableIndex} from "typeorm";
|
||||
|
||||
/**
|
||||
* Boilerplate for creating generic index
|
||||
@@ -78,3 +78,15 @@ export const filterIndices = (prefix: string) => {
|
||||
itemIsIndex(prefix)
|
||||
]
|
||||
}
|
||||
|
||||
export const tableHasData = async (runner: QueryRunner, name: string): Promise<boolean | null> => {
|
||||
const countRes = await runner.query(`select count(*) from ${name}`);
|
||||
let hasRows = null;
|
||||
if (Array.isArray(countRes) && countRes[0] !== null) {
|
||||
const {
|
||||
'count(*)': count
|
||||
} = countRes[0] || {};
|
||||
hasRows = count !== 0;
|
||||
}
|
||||
return hasRows;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import {MigrationInterface, QueryRunner, Table} from "typeorm"
|
||||
import {createdAtColumn, createdAtIndex, idIndex, index, randomIdColumn, timeAtColumn} from "../MigrationUtil";
|
||||
|
||||
export class Guests1658930394548 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const dbType = queryRunner.connection.driver.options.type;
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'Guests',
|
||||
columns: [
|
||||
randomIdColumn(),
|
||||
{
|
||||
name: 'authorName',
|
||||
type: 'varchar',
|
||||
length: '200',
|
||||
isNullable: false,
|
||||
},
|
||||
{
|
||||
name: 'type',
|
||||
type: 'varchar',
|
||||
isNullable: false,
|
||||
length: '50'
|
||||
},
|
||||
{
|
||||
name: 'guestOfId',
|
||||
type: 'varchar',
|
||||
length: '20',
|
||||
isNullable: true
|
||||
},
|
||||
timeAtColumn('expiresAt', dbType, true),
|
||||
createdAtColumn(dbType),
|
||||
],
|
||||
indices: [
|
||||
idIndex('Guests', true),
|
||||
createdAtIndex('guests'),
|
||||
index('guest', ['expiresAt'], false)
|
||||
]
|
||||
}),
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
145
src/Common/Migrations/Database/Server/1660228987769-invites.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import {MigrationInterface, QueryRunner, Table, TableColumn} from "typeorm"
|
||||
import {createdAtColumn, createdAtIndex, idIndex, index, randomIdColumn, tableHasData, timeAtColumn} from "../MigrationUtil";
|
||||
|
||||
export class invites1660228987769 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const dbType = queryRunner.connection.driver.options.type;
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'SubredditInvite',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: 'botId',
|
||||
type: 'varchar',
|
||||
length: '20',
|
||||
isNullable: false
|
||||
},
|
||||
{
|
||||
name: 'subreddit',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: false
|
||||
},
|
||||
{
|
||||
name: 'guests',
|
||||
type: 'text',
|
||||
isNullable: true
|
||||
},
|
||||
{
|
||||
name: 'initialConfig',
|
||||
type: 'text',
|
||||
isNullable: true
|
||||
},
|
||||
createdAtColumn(dbType),
|
||||
timeAtColumn('expiresAt', dbType, true)
|
||||
],
|
||||
}),
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (await queryRunner.hasTable('Invite')) {
|
||||
|
||||
await queryRunner.renameTable('Invite', 'BotInvite');
|
||||
const table = await queryRunner.getTable('BotInvite') as Table;
|
||||
|
||||
await queryRunner.addColumns(table, [
|
||||
new TableColumn({
|
||||
name: 'initialConfig',
|
||||
type: 'text',
|
||||
isNullable: true
|
||||
}),
|
||||
new TableColumn({
|
||||
name: 'guests',
|
||||
type: 'text',
|
||||
isNullable: true
|
||||
})
|
||||
]);
|
||||
|
||||
queryRunner.connection.logger.logSchemaBuild(`Table 'Invite' has been renamed 'BotInvite'. If there are existing rows on this table they will need to be recreated.`);
|
||||
|
||||
} else {
|
||||
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'BotInvite',
|
||||
columns: [
|
||||
{
|
||||
name: 'id',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isPrimary: true,
|
||||
},
|
||||
{
|
||||
name: 'clientId',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
},
|
||||
{
|
||||
name: 'clientSecret',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
},
|
||||
{
|
||||
name: 'redirectUri',
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'creator',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
},
|
||||
{
|
||||
name: 'permissions',
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
name: 'instance',
|
||||
type: 'varchar',
|
||||
length: '255',
|
||||
isNullable: true
|
||||
},
|
||||
{
|
||||
name: 'overwrite',
|
||||
type: 'boolean',
|
||||
isNullable: true,
|
||||
},
|
||||
{
|
||||
name: 'subreddits',
|
||||
type: 'text',
|
||||
isNullable: true
|
||||
},
|
||||
{
|
||||
name: 'guests',
|
||||
type: 'text',
|
||||
isNullable: true
|
||||
},
|
||||
{
|
||||
name: 'initialConfig',
|
||||
type: 'text',
|
||||
isNullable: true
|
||||
},
|
||||
createdAtColumn(dbType),
|
||||
timeAtColumn('expiresAt', dbType, true)
|
||||
],
|
||||
}),
|
||||
true,
|
||||
true,
|
||||
true
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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> {
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import {MigrationInterface, QueryRunner} from "typeorm"
|
||||
import {tableHasData} from "../MigrationUtil";
|
||||
|
||||
export class removeInvites1660588028346 implements MigrationInterface {
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
const dbType = queryRunner.connection.driver.options.type;
|
||||
|
||||
if (dbType === 'sqljs' && await queryRunner.hasTable('Invite')) {
|
||||
// const countRes = await queryRunner.query('select count(*) from Invite');
|
||||
// let hasNoRows = null;
|
||||
// if (Array.isArray(countRes) && countRes[0] !== null) {
|
||||
// const {
|
||||
// 'count(*)': count
|
||||
// } = countRes[0] || {};
|
||||
// hasNoRows = count === 0;
|
||||
// }
|
||||
|
||||
const hasRows = await tableHasData(queryRunner, 'Invite');
|
||||
|
||||
if (hasRows === false) {
|
||||
await queryRunner.dropTable('Invite');
|
||||
} else {
|
||||
let prefix = hasRows === null ? `Could not determine if SQL.js 'web' database had the table 'Invite' --` : `SQL.js 'web' database had the table 'Invite' and it is not empty --`
|
||||
queryRunner.connection.logger.logSchemaBuild(`${prefix} This table is being replaced by 'BotInvite' table in 'app' database. If you have existing invites you will need to recreate them.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
}
|
||||
|
||||
}
|
||||
242
src/Common/OpenCVService.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
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}]
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ISession } from "connect-typeorm";
|
||||
import { Column, Entity, Index, PrimaryColumn } from "typeorm";
|
||||
import { Column, Entity, Index, PrimaryColumn, DeleteDateColumn } from "typeorm";
|
||||
@Entity()
|
||||
export class ClientSession implements ISession {
|
||||
@Index()
|
||||
@@ -12,6 +12,6 @@ export class ClientSession implements ISession {
|
||||
@Column("text")
|
||||
public json = "";
|
||||
|
||||
@Column({ name: 'destroyedAt', nullable: true })
|
||||
@DeleteDateColumn({ name: 'destroyedAt', nullable: true })
|
||||
destroyedAt?: Date;
|
||||
}
|
||||
|
||||
@@ -107,8 +107,7 @@ var bmvbhash_even = function(data: BlockImageData, bits: number) {
|
||||
return bits_to_hexhash(result);
|
||||
};
|
||||
|
||||
var bmvbhash = function(data: BlockImageData, bits: number) {
|
||||
var result = [];
|
||||
var bmvbhash = function(data: BlockImageData, bits: number, calculateFlipped: boolean = false): string | [string, string] {
|
||||
|
||||
var i, j, x, y;
|
||||
var block_width, block_height;
|
||||
@@ -198,30 +197,51 @@ var bmvbhash = function(data: BlockImageData, bits: number) {
|
||||
}
|
||||
}
|
||||
|
||||
for (i = 0; i < bits; i++) {
|
||||
for (j = 0; j < bits; j++) {
|
||||
result.push(blocks[i][j]);
|
||||
const blocksFlipped: number[][] | undefined = calculateFlipped ? [] : undefined;
|
||||
if(blocksFlipped !== undefined) {
|
||||
for(const row of blocks) {
|
||||
const flippedRow = [...row];
|
||||
flippedRow.reverse();
|
||||
blocksFlipped.push(flippedRow);
|
||||
}
|
||||
}
|
||||
|
||||
translate_blocks_to_bits(result, block_width * block_height);
|
||||
return bits_to_hexhash(result);
|
||||
if(blocksFlipped !== undefined) {
|
||||
const result = [];
|
||||
const resultFlip = [];
|
||||
for (i = 0; i < bits; i++) {
|
||||
for (j = 0; j < bits; j++) {
|
||||
result.push(blocks[i][j]);
|
||||
resultFlip.push(blocksFlipped[i][j])
|
||||
}
|
||||
}
|
||||
|
||||
translate_blocks_to_bits(result, block_width * block_height);
|
||||
translate_blocks_to_bits(resultFlip, block_width * block_height);
|
||||
return [bits_to_hexhash(result), bits_to_hexhash(resultFlip)];
|
||||
} else {
|
||||
const result = [];
|
||||
for (i = 0; i < bits; i++) {
|
||||
for (j = 0; j < bits; j++) {
|
||||
result.push(blocks[i][j]);
|
||||
}
|
||||
}
|
||||
|
||||
translate_blocks_to_bits(result, block_width * block_height);
|
||||
return bits_to_hexhash(result);
|
||||
}
|
||||
};
|
||||
|
||||
var blockhashData = function(imgData: BlockImageData, bits: number, method: number) {
|
||||
var hash;
|
||||
var blockhashData = function(imgData: BlockImageData, bits: number, method: number, calculateFlipped: boolean): string | [string, string] {
|
||||
|
||||
if (method === 1) {
|
||||
hash = bmvbhash_even(imgData, bits);
|
||||
return bmvbhash_even(imgData, bits);
|
||||
}
|
||||
else if (method === 2) {
|
||||
hash = bmvbhash(imgData, bits);
|
||||
}
|
||||
else {
|
||||
throw new Error("Bad hashing method");
|
||||
return bmvbhash(imgData, bits, calculateFlipped);
|
||||
}
|
||||
|
||||
return hash;
|
||||
throw new Error("Bad hashing method");
|
||||
};
|
||||
|
||||
export const blockhash = async function(src: Sharp, bits: number, method: number = 2): Promise<string> {
|
||||
@@ -230,5 +250,14 @@ export const blockhash = async function(src: Sharp, bits: number, method: number
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
data: buff,
|
||||
}, bits, method);
|
||||
}, bits, method, false) as string;
|
||||
};
|
||||
|
||||
export const blockhashAndFlipped = async function(src: Sharp, bits: number, method: number = 2): Promise<[string, string]> {
|
||||
const {data: buff, info} = await src.ensureAlpha().raw().toBuffer({resolveWithObject: true});
|
||||
return blockhashData({
|
||||
width: info.width,
|
||||
height: info.height,
|
||||
data: buff,
|
||||
}, bits, method, true) as [string, string];
|
||||
};
|
||||
|
||||
@@ -42,4 +42,4 @@ export const filterCriteriaDefault: FilterCriteriaDefaults = {
|
||||
export const defaultDataDir = path.resolve(__dirname, '../..');
|
||||
export const defaultConfigFilenames = ['config.json', 'config.yaml'];
|
||||
|
||||
export const VERSION = '0.11.4';
|
||||
export const VERSION = '0.12.0';
|
||||
|
||||
@@ -1060,6 +1060,16 @@ export interface SubredditOverrides {
|
||||
* */
|
||||
retention?: EventRetentionPolicyRange
|
||||
}
|
||||
|
||||
/**
|
||||
* The relative URL to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`
|
||||
*
|
||||
* This will override the default relative URL as well as any URL set at the bot-level
|
||||
*
|
||||
* @default "botconfig/contextbot"
|
||||
* @examples ["botconfig/contextbot"]
|
||||
* */
|
||||
wikiConfig?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1479,20 +1489,6 @@ export interface OperatorJsonConfig {
|
||||
storage?: 'database' | 'cache'
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings related to oauth flow invites
|
||||
* */
|
||||
invites?: {
|
||||
/**
|
||||
* Number of seconds an invite should be valid for
|
||||
*
|
||||
* If `0` or not specified (default) invites do not expire
|
||||
*
|
||||
* @default 0
|
||||
* @examples [0]
|
||||
* */
|
||||
maxAge?: number
|
||||
}
|
||||
/**
|
||||
* The default log level to filter to in the web interface
|
||||
*
|
||||
@@ -1548,6 +1544,8 @@ export interface OperatorJsonConfig {
|
||||
secret?: string,
|
||||
/**
|
||||
* A friendly name for this server. This will override `friendly` in `BotConnection` if specified.
|
||||
*
|
||||
* If none is set one is randomly generated.
|
||||
* */
|
||||
friendly?: string,
|
||||
}
|
||||
@@ -1659,9 +1657,6 @@ export interface OperatorConfig extends OperatorJsonConfig {
|
||||
secret?: string,
|
||||
storage?: 'database' | 'cache'
|
||||
},
|
||||
invites: {
|
||||
maxAge: number
|
||||
},
|
||||
logLevel?: LogLevel,
|
||||
maxLogs: number,
|
||||
clients: BotConnection[]
|
||||
|
||||
@@ -21,7 +21,8 @@ import {ContributorActionJson} from "../Action/ContributorAction";
|
||||
import {SentimentRuleJSONConfig} from "../Rule/SentimentRule";
|
||||
import {ModNoteActionJson} from "../Action/ModNoteAction";
|
||||
import {IncludesData} from "./Infrastructure/Includes";
|
||||
import { SubmissionActionJson } from "../Action/SubmissionAction";
|
||||
|
||||
export type RuleObjectJsonTypes = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | SentimentRuleJSONConfig
|
||||
|
||||
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | ModNoteActionJson | string | IncludesData;
|
||||
export type ActionJson = CommentActionJson | SubmissionActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | DispatchActionJson | CancelDispatchActionJson | ContributorActionJson | ModNoteActionJson | string | IncludesData;
|
||||
|
||||
@@ -2,7 +2,7 @@ import winston, {Logger} from "winston";
|
||||
import {
|
||||
asNamedCriteria, asWikiContext,
|
||||
buildCachePrefix, buildFilter, castToBool,
|
||||
createAjvFactory, fileOrDirectoryIsWriteable,
|
||||
createAjvFactory, fileOrDirectoryIsWriteable, generateRandomName,
|
||||
mergeArr, mergeFilters,
|
||||
normalizeName,
|
||||
overwriteMerge,
|
||||
@@ -98,6 +98,7 @@ import {SubredditResources} from "./Subreddit/SubredditResources";
|
||||
import {asIncludesData, IncludesData, IncludesString} from "./Common/Infrastructure/Includes";
|
||||
import ConfigParseError from "./Utils/ConfigParseError";
|
||||
import {InfluxClient} from "./Common/Influx/InfluxClient";
|
||||
import {BotInvite} from "./Common/Entities/BotInvite";
|
||||
|
||||
export interface ConfigBuilderOptions {
|
||||
logger: Logger,
|
||||
@@ -1224,9 +1225,6 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
maxAge: sessionMaxAge = 86400,
|
||||
storage: sessionStorage = undefined,
|
||||
} = {},
|
||||
invites: {
|
||||
maxAge: inviteMaxAge = 0,
|
||||
} = {},
|
||||
clients,
|
||||
credentials: webCredentials,
|
||||
operators,
|
||||
@@ -1331,6 +1329,8 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
}
|
||||
const webDbConfig = createDatabaseConfig(realdbConnectionWeb);
|
||||
|
||||
const appDataSource = await createAppDatabaseConnection(dbConfig, appLogger);
|
||||
|
||||
let influx: InfluxClient | undefined = undefined;
|
||||
if(influxConfig !== undefined) {
|
||||
const tags = friendly !== undefined ? {server: friendly} : undefined;
|
||||
@@ -1338,6 +1338,28 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
await influx.isReady();
|
||||
}
|
||||
|
||||
/* let friendlyId: string;
|
||||
if (friendly === undefined) {
|
||||
let randFriendly: string = generateRandomName();
|
||||
// see if we can get invites to check for unique name
|
||||
// if this is a new instance will not be able to get it but try anyway
|
||||
try {
|
||||
const inviteRepo = appDataSource.getRepository(BotInvite);
|
||||
const exists = async (name: string) => {
|
||||
const existing = await inviteRepo.findBy({instance: name});
|
||||
return existing.length > 0;
|
||||
}
|
||||
while (await exists(randFriendly)) {
|
||||
randFriendly = generateRandomName();
|
||||
}
|
||||
} catch (e: any) {
|
||||
// something went wrong, just ignore this
|
||||
}
|
||||
friendlyId = randFriendly;
|
||||
} else {
|
||||
friendlyId = friendly;
|
||||
}*/
|
||||
|
||||
const config: OperatorConfig = {
|
||||
mode,
|
||||
operator: {
|
||||
@@ -1351,7 +1373,7 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
frequency,
|
||||
minFrequency
|
||||
},
|
||||
database: await createAppDatabaseConnection(dbConfig, appLogger),
|
||||
database: appDataSource,
|
||||
databaseConfig: {
|
||||
connection: dbConfig,
|
||||
migrations,
|
||||
@@ -1371,9 +1393,6 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
},
|
||||
port,
|
||||
storage: webStorage,
|
||||
invites: {
|
||||
maxAge: inviteMaxAge,
|
||||
},
|
||||
session: {
|
||||
secret: sessionSecretFromConfig,
|
||||
maxAge: sessionMaxAge,
|
||||
@@ -1387,7 +1406,7 @@ export const buildOperatorConfigWithDefaults = async (data: OperatorJsonConfig):
|
||||
api: {
|
||||
port: apiPort,
|
||||
secret: apiSecret,
|
||||
friendly
|
||||
friendly,
|
||||
},
|
||||
bots: [],
|
||||
credentials,
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/ActivityWindow";
|
||||
import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons";
|
||||
import {ImageHashCacheData} from "../Common/Infrastructure/Atomic";
|
||||
|
||||
const parseLink = parseUsableLinkIdentifier();
|
||||
|
||||
@@ -195,21 +196,21 @@ export class RecentActivityRule extends Rule {
|
||||
let filteredActivity: (Submission|Comment)[] = [];
|
||||
let analysisTimes: number[] = [];
|
||||
let referenceImage: ImageData | undefined;
|
||||
let refHash: Required<ImageHashCacheData> | undefined;
|
||||
if (this.imageDetection.enable) {
|
||||
try {
|
||||
referenceImage = ImageData.fromSubmission(item);
|
||||
referenceImage.setPreferredResolutionByWidth(800);
|
||||
if(this.imageDetection.hash.enable) {
|
||||
let refHash: string | undefined;
|
||||
if(this.imageDetection.hash.ttl !== undefined) {
|
||||
refHash = await this.resources.getImageHash(referenceImage);
|
||||
if(refHash === undefined) {
|
||||
refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
await this.resources.setImageHash(referenceImage, refHash, this.imageDetection.hash.ttl);
|
||||
} else if(refHash.length !== bitsToHexLength(this.imageDetection.hash.bits)) {
|
||||
await this.resources.setImageHash(referenceImage, this.imageDetection.hash.ttl);
|
||||
} else if(refHash.original.length !== bitsToHexLength(this.imageDetection.hash.bits)) {
|
||||
this.logger.warn('Reference image hash length did not correspond to bits specified in config. Recomputing...');
|
||||
refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
await this.resources.setImageHash(referenceImage, refHash, this.imageDetection.hash.ttl);
|
||||
await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
await this.resources.setImageHash(referenceImage, this.imageDetection.hash.ttl);
|
||||
}
|
||||
} else {
|
||||
refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
@@ -244,29 +245,38 @@ export class RecentActivityRule extends Rule {
|
||||
}
|
||||
// only do image detection if regular URL comparison and other conditions fail first
|
||||
// to reduce CPU/bandwidth usage
|
||||
if (referenceImage !== undefined) {
|
||||
if (referenceImage !== undefined && refHash !== undefined) {
|
||||
try {
|
||||
let imgData = ImageData.fromSubmission(x);
|
||||
imgData.setPreferredResolutionByWidth(800);
|
||||
if(this.imageDetection.hash.enable) {
|
||||
let compareHash: string | undefined;
|
||||
let compareHash: Required<ImageHashCacheData> | undefined;
|
||||
if(this.imageDetection.hash.ttl !== undefined) {
|
||||
compareHash = await this.resources.getImageHash(imgData);
|
||||
}
|
||||
if(compareHash === undefined)
|
||||
if(compareHash === undefined || compareHash.original.length !== refHash.original.length)
|
||||
{
|
||||
if(compareHash !== undefined) {
|
||||
this.logger.debug(`Hash lengths were not the same! Will need to recompute compare hash to match reference.\n\nReference: ${referenceImage.basePath} has is ${refHash.original.length} char long | Comparing: ${imgData.basePath} has is ${compareHash} ${compareHash.original.length} long`);
|
||||
}
|
||||
compareHash = await imgData.hash(this.imageDetection.hash.bits);
|
||||
if(this.imageDetection.hash.ttl !== undefined) {
|
||||
await this.resources.setImageHash(imgData, compareHash, this.imageDetection.hash.ttl);
|
||||
await this.resources.setImageHash(imgData, this.imageDetection.hash.ttl);
|
||||
}
|
||||
}
|
||||
const refHash = await referenceImage.hash(this.imageDetection.hash.bits);
|
||||
if(refHash.length !== compareHash.length) {
|
||||
this.logger.debug(`Hash lengths were not the same! Will need to recompute compare hash to match reference.\n\nReference: ${referenceImage.baseUrl} has is ${refHash.length} char long | Comparing: ${imgData.baseUrl} has is ${compareHash} ${compareHash.length} long`);
|
||||
compareHash = await imgData.hash(this.imageDetection.hash.bits)
|
||||
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;
|
||||
}
|
||||
}
|
||||
const distance = leven(refHash, compareHash);
|
||||
const diff = (distance/refHash.length)*100;
|
||||
|
||||
|
||||
// return image if hard is defined and diff is less
|
||||
|
||||
@@ -46,6 +46,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -704,6 +707,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2173,7 +2190,16 @@
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"description": "(Optional) A mod-readable note added to the removal reason for this Activity. Can use Templating.\n\nThis note (and removal reasons) are only visible on New Reddit",
|
||||
"type": "string"
|
||||
},
|
||||
"reasonId": {
|
||||
"description": "(Optional) The ID of the Removal Reason to use\n\nRemoval reasons are only visible on New Reddit\n\nTo find IDs for removal reasons check the \"Removal Reasons\" popup located in the CM dashboard config editor for your subreddit\n\nMore info on Removal Reasons: https://mods.reddithelp.com/hc/en-us/articles/360010094892-Removal-Reasons",
|
||||
"type": "string"
|
||||
},
|
||||
"spam": {
|
||||
"description": "(Optional) Mark Activity as spam",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -2283,6 +2309,160 @@
|
||||
],
|
||||
"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": {
|
||||
"description": "Different attributes a `Submission` can be in. Only include a property if you want to check it.",
|
||||
"examples": [
|
||||
|
||||
@@ -1376,6 +1376,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1447,6 +1461,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4678,7 +4695,16 @@
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"description": "(Optional) A mod-readable note added to the removal reason for this Activity. Can use Templating.\n\nThis note (and removal reasons) are only visible on New Reddit",
|
||||
"type": "string"
|
||||
},
|
||||
"reasonId": {
|
||||
"description": "(Optional) The ID of the Removal Reason to use\n\nRemoval reasons are only visible on New Reddit\n\nTo find IDs for removal reasons check the \"Removal Reasons\" popup located in the CM dashboard config editor for your subreddit\n\nMore info on Removal Reasons: https://mods.reddithelp.com/hc/en-us/articles/360010094892-Removal-Reasons",
|
||||
"type": "string"
|
||||
},
|
||||
"spam": {
|
||||
"description": "(Optional) Mark Activity as spam",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -5652,6 +5678,160 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
@@ -5715,6 +5895,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -1199,6 +1199,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1270,6 +1284,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4133,7 +4150,16 @@
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"description": "(Optional) A mod-readable note added to the removal reason for this Activity. Can use Templating.\n\nThis note (and removal reasons) are only visible on New Reddit",
|
||||
"type": "string"
|
||||
},
|
||||
"reasonId": {
|
||||
"description": "(Optional) The ID of the Removal Reason to use\n\nRemoval reasons are only visible on New Reddit\n\nTo find IDs for removal reasons check the \"Removal Reasons\" popup located in the CM dashboard config editor for your subreddit\n\nMore info on Removal Reasons: https://mods.reddithelp.com/hc/en-us/articles/360010094892-Removal-Reasons",
|
||||
"type": "string"
|
||||
},
|
||||
"spam": {
|
||||
"description": "(Optional) Mark Activity as spam",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -4977,6 +5003,160 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
@@ -5040,6 +5220,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -2030,6 +2030,14 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"wikiConfig": {
|
||||
"default": "botconfig/contextbot",
|
||||
"description": "The relative URL to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`\n\nThis will override the default relative URL as well as any URL set at the bot-level",
|
||||
"examples": [
|
||||
"botconfig/contextbot"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -2132,7 +2140,7 @@
|
||||
"description": "Configuration for the **Server** application. See [Architecture Documentation](https://github.com/FoxxMD/context-mod/blob/master/docs/serverClientArchitecture.md) for more info",
|
||||
"properties": {
|
||||
"friendly": {
|
||||
"description": "A friendly name for this server. This will override `friendly` in `BotConnection` if specified.",
|
||||
"description": "A friendly name for this server. This will override `friendly` in `BotConnection` if specified.\n\nIf none is set one is randomly generated.",
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
@@ -2425,20 +2433,6 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"invites": {
|
||||
"description": "Settings related to oauth flow invites",
|
||||
"properties": {
|
||||
"maxAge": {
|
||||
"default": 0,
|
||||
"description": "Number of seconds an invite should be valid for\n\n If `0` or not specified (default) invites do not expire",
|
||||
"examples": [
|
||||
0
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"logLevel": {
|
||||
"description": "The default log level to filter to in the web interface\n\nIf not specified or `null` will be same as global `logLevel`",
|
||||
"enum": [
|
||||
|
||||
@@ -1196,6 +1196,20 @@
|
||||
"sticky": {
|
||||
"description": "Stick the comment after creation?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"targets": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "Specify where this comment should be made\n\nValid values: 'self' | 'parent' | [reddit permalink]\n\n'self' and 'parent' are special targets that are relative to the Activity being processed:\n* When Activity is Submission => 'parent' does nothing\n* When Activity is Comment\n * 'self' => reply to Activity\n * 'parent' => make a top-level comment in the Submission the Comment is in\n\nIf target is not self/parent then CM assumes the value is a reddit permalink and will attempt to make a comment to that Activity"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -1267,6 +1281,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
@@ -4249,7 +4266,16 @@
|
||||
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
|
||||
"type": "string"
|
||||
},
|
||||
"note": {
|
||||
"description": "(Optional) A mod-readable note added to the removal reason for this Activity. Can use Templating.\n\nThis note (and removal reasons) are only visible on New Reddit",
|
||||
"type": "string"
|
||||
},
|
||||
"reasonId": {
|
||||
"description": "(Optional) The ID of the Removal Reason to use\n\nRemoval reasons are only visible on New Reddit\n\nTo find IDs for removal reasons check the \"Removal Reasons\" popup located in the CM dashboard config editor for your subreddit\n\nMore info on Removal Reasons: https://mods.reddithelp.com/hc/en-us/articles/360010094892-Removal-Reasons",
|
||||
"type": "string"
|
||||
},
|
||||
"spam": {
|
||||
"description": "(Optional) Mark Activity as spam",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
@@ -5223,6 +5249,160 @@
|
||||
],
|
||||
"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": {
|
||||
"properties": {
|
||||
"actions": {
|
||||
@@ -5286,6 +5466,9 @@
|
||||
{
|
||||
"$ref": "#/definitions/ModNoteActionJson"
|
||||
},
|
||||
{
|
||||
"$ref": "#/definitions/SubmissionActionJson"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ import {parseFromJsonOrYamlToObject} from "../Common/Config/ConfigUtil";
|
||||
import {FilterCriteriaDefaults} from "../Common/Infrastructure/Filters/FilterShapes";
|
||||
import {InfluxClient} from "../Common/Influx/InfluxClient";
|
||||
import { Point } from "@influxdata/influxdb-client";
|
||||
import {NormalizedManagerResponse} from "../Web/Common/interfaces";
|
||||
import {guestEntityToApiGuest} from "../Common/Entities/Guest/GuestEntity";
|
||||
|
||||
export interface RunningState {
|
||||
state: RunState,
|
||||
@@ -1493,6 +1495,11 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
|
||||
async startQueue(causedBy: Invokee = 'system', options?: ManagerStateChangeOption) {
|
||||
|
||||
if(!this.validConfigLoaded) {
|
||||
this.logger.warn('Cannot start queue while manager has an invalid configuration');
|
||||
return;
|
||||
}
|
||||
|
||||
if(this.activityRepo === undefined) {
|
||||
this.activityRepo = this.resources.database.getRepository(Activity);
|
||||
}
|
||||
@@ -1791,4 +1798,13 @@ export class Manager extends EventEmitter implements RunningStates {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
toNormalizedManager(): NormalizedManagerResponse {
|
||||
return {
|
||||
name: this.displayLabel,
|
||||
subreddit: this.subreddit.display_name,
|
||||
subredditNormal: parseRedditEntity(this.subreddit.display_name).name,
|
||||
guests: this.managerEntity.getGuests().map(x => guestEntityToApiGuest(x))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ import {
|
||||
filterByTimeRequirement,
|
||||
asSubreddit,
|
||||
modActionCriteriaSummary,
|
||||
parseRedditFullname
|
||||
parseRedditFullname, asStrongImageHashCache
|
||||
} from "../util";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
@@ -120,7 +120,7 @@ import {
|
||||
} from "../Common/Infrastructure/Filters/FilterCriteria";
|
||||
import {
|
||||
ActivitySource, ConfigFragmentValidationFunc, DurationVal,
|
||||
EventRetentionPolicyRange,
|
||||
EventRetentionPolicyRange, ImageHashCacheData,
|
||||
JoinOperands,
|
||||
ModActionType,
|
||||
ModeratorNameCriteria, ModUserNoteLabel, statFrequencies, StatisticFrequency,
|
||||
@@ -146,7 +146,7 @@ import {
|
||||
ActivityType,
|
||||
AuthorHistorySort,
|
||||
CachedFetchedActivitiesResult, FetchedActivitiesResult,
|
||||
SnoowrapActivity
|
||||
SnoowrapActivity, SubredditRemovalReason
|
||||
} from "../Common/Infrastructure/Reddit";
|
||||
import {AuthorCritPropHelper} from "../Common/Infrastructure/Filters/AuthorCritPropHelper";
|
||||
import {NoopLogger} from "../Utils/loggerFactory";
|
||||
@@ -994,7 +994,7 @@ export class SubredditResources {
|
||||
hash = `sub-${item.name}`;
|
||||
if (tryToFetch && item instanceof Submission) {
|
||||
// @ts-ignore
|
||||
const itemToCache = await item.fetch();
|
||||
const itemToCache = await item.refresh();
|
||||
await this.cache.set(hash, itemToCache, {ttl: this.submissionTTL});
|
||||
return itemToCache;
|
||||
} else {
|
||||
@@ -1006,7 +1006,7 @@ export class SubredditResources {
|
||||
hash = `comm-${item.name}`;
|
||||
if (tryToFetch && item instanceof Comment) {
|
||||
// @ts-ignore
|
||||
const itemToCache = await item.fetch();
|
||||
const itemToCache = await item.refresh();
|
||||
await this.cache.set(hash, itemToCache, {ttl: this.commentTTL});
|
||||
return itemToCache;
|
||||
} else {
|
||||
@@ -1016,8 +1016,12 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
return item;
|
||||
} catch (e) {
|
||||
throw new ErrorWithCause('Error occurred while trying to add Activity to cache', {cause: e});
|
||||
} catch (e: any) {
|
||||
if(e.message !== undefined && e.message.includes('Cannot read properties of undefined (reading \'constructor\')')) {
|
||||
throw new ErrorWithCause('Error occurred while trying to add Activity to cache (Comment likely does not exist)', {cause: e});
|
||||
} else {
|
||||
throw new ErrorWithCause('Error occurred while trying to add Activity to cache', {cause: e});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1102,8 +1106,9 @@ export class SubredditResources {
|
||||
return subreddit as Subreddit;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.error('Error while trying to fetch a cached subreddit', err);
|
||||
throw err.logged;
|
||||
const cmError = new CMError('Error while trying to fetch a cached subreddit', {cause: err, logged: true});
|
||||
this.logger.error(cmError);
|
||||
throw cmError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3353,14 +3358,26 @@ export class SubredditResources {
|
||||
return he.decode(Mustache.render(footerRawContent, {subName, permaLink, modmailLink, botLink: BOT_LINK}));
|
||||
}
|
||||
|
||||
async getImageHash(img: ImageData): Promise<string|undefined> {
|
||||
const hash = `imgHash-${img.baseUrl}`;
|
||||
async getImageHash(img: ImageData): Promise<Required<ImageHashCacheData>|undefined> {
|
||||
|
||||
if(img.hashResult !== undefined && img.hashResultFlipped !== undefined) {
|
||||
return img.toHashCache() as Required<ImageHashCacheData>;
|
||||
}
|
||||
|
||||
const hash = `imgHash-${img.basePath}`;
|
||||
const result = await this.cache.get(hash) as string | undefined | null;
|
||||
this.stats.cache.imageHash.requests++
|
||||
this.stats.cache.imageHash.requestTimestamps.push(Date.now());
|
||||
await this.stats.cache.imageHash.identifierRequestCount.set(hash, (await this.stats.cache.imageHash.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
if(result !== undefined && result !== null) {
|
||||
return result;
|
||||
try {
|
||||
const data = JSON.parse(result);
|
||||
if(asStrongImageHashCache(data)) {
|
||||
return data;
|
||||
}
|
||||
} catch (e) {
|
||||
// had old values, just drop it
|
||||
}
|
||||
}
|
||||
this.stats.cache.commentCheck.miss++;
|
||||
return undefined;
|
||||
@@ -3371,8 +3388,8 @@ export class SubredditResources {
|
||||
// return hash;
|
||||
}
|
||||
|
||||
async setImageHash(img: ImageData, hash: string, ttl: number): Promise<void> {
|
||||
await this.cache.set(`imgHash-${img.baseUrl}`, hash, {ttl});
|
||||
async setImageHash(img: ImageData, ttl: number): Promise<void> {
|
||||
await this.cache.set(`imgHash-${img.basePath}`, img.toHashCache() as Required<ImageHashCacheData>, {ttl});
|
||||
// const hash = await this.cache.wrap(img.baseUrl, async () => await img.hash(true), { ttl }) as string;
|
||||
// if(img.hashResult === undefined) {
|
||||
// img.hashResult = hash;
|
||||
@@ -3386,6 +3403,21 @@ export class SubredditResources {
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async getSubredditRemovalReasons(): Promise<SubredditRemovalReason[]> {
|
||||
if(this.wikiTTL !== false) {
|
||||
return await this.cache.wrap(`removalReasons`, async () => {
|
||||
const res = await this.client.getSubredditRemovalReasons(this.subreddit.display_name);
|
||||
return Object.values(res.data);
|
||||
}, { ttl: this.wikiTTL }) as SubredditRemovalReason[];
|
||||
}
|
||||
const res = await this.client.getSubredditRemovalReasons(this.subreddit.display_name);
|
||||
return Object.values(res.data);
|
||||
}
|
||||
|
||||
async getSubredditRemovalReasonById(id: string): Promise<SubredditRemovalReason | undefined> {
|
||||
return (await this.getSubredditRemovalReasons()).find(x => x.id === id);
|
||||
}
|
||||
}
|
||||
|
||||
export class BotResourcesManager {
|
||||
|
||||
@@ -3,7 +3,8 @@ import {Submission, Subreddit, Comment} from "snoowrap/dist/objects";
|
||||
import {parseSubredditName} from "../util";
|
||||
import {ModUserNoteLabel} from "../Common/Infrastructure/Atomic";
|
||||
import {CreateModNoteData, ModNote, ModNoteRaw, ModNoteSnoowrapPopulated} from "../Subreddit/ModNotes/ModNote";
|
||||
import {SimpleError} from "./Errors";
|
||||
import {CMError, SimpleError} from "./Errors";
|
||||
import {RawSubredditRemovalReasonData, SnoowrapActivity} from "../Common/Infrastructure/Reddit";
|
||||
|
||||
// const proxyFactory = (endpoint: string) => {
|
||||
// return class ProxiedSnoowrap extends Snoowrap {
|
||||
@@ -140,6 +141,47 @@ export class ExtendedSnoowrap extends Snoowrap {
|
||||
}) as { created: ModNoteRaw };
|
||||
return new ModNote(response.created, this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a removal reason and/or mod note to a REMOVED Activity
|
||||
*
|
||||
* The activity must already be removed for this call to succeed. This is an UNDOCUMENTED endpoint.
|
||||
*
|
||||
* @see https://github.com/praw-dev/praw/blob/b22e1f514d68d36545daf62e8a8d6c6c8caf782b/praw/endpoints.py#L149 for endpoint
|
||||
* @see https://github.com/praw-dev/praw/blob/b22e1f514d68d36545daf62e8a8d6c6c8caf782b/praw/models/reddit/mixins/__init__.py#L28 for usage
|
||||
* */
|
||||
async addRemovalReason(item: SnoowrapActivity, note?: string, reason?: string) {
|
||||
try {
|
||||
if(note === undefined && reason === undefined) {
|
||||
throw new CMError(`Must provide either a note or reason in order to add removal reason on Activity ${item.name}`, {isSerious: false});
|
||||
}
|
||||
await this.oauthRequest({
|
||||
uri: 'api/v1/modactions/removal_reasons',
|
||||
method: 'post',
|
||||
body: {
|
||||
item_ids: [item.name],
|
||||
mod_note: note ?? null,
|
||||
reason_id: reason ?? null,
|
||||
},
|
||||
});
|
||||
} catch(e: any) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of New Reddit removal reasons for a Subreddit
|
||||
*
|
||||
* This is an UNDOCUMENTED endpoint.
|
||||
*
|
||||
* @see https://github.com/praw-dev/praw/blob/b22e1f514d68d36545daf62e8a8d6c6c8caf782b/praw/endpoints.py#L151 for endpoint
|
||||
* */
|
||||
async getSubredditRemovalReasons(sub: Subreddit | string): Promise<RawSubredditRemovalReasonData> {
|
||||
return await this.oauthRequest({
|
||||
uri: `api/v1/${typeof sub === 'string' ? sub : sub.display_name}/removal_reasons`,
|
||||
method: 'get'
|
||||
}) as RawSubredditRemovalReasonData;
|
||||
}
|
||||
}
|
||||
|
||||
export class RequestTrackingSnoowrap extends ExtendedSnoowrap {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import {URL} from "url";
|
||||
import {Logger} from "winston";
|
||||
import {BotInstance, CMInstanceInterface, CMInstanceInterface as CMInterface} from "../interfaces";
|
||||
import dayjs from 'dayjs';
|
||||
import {BotConnection, LogInfo} from "../../Common/interfaces";
|
||||
import normalizeUrl from "normalize-url";
|
||||
import {HeartbeatResponse} from "../Common/interfaces";
|
||||
import {
|
||||
BotInstance,
|
||||
CMInstanceInterface as CMInterface,
|
||||
CMInstanceInterface,
|
||||
HeartbeatResponse
|
||||
} from "../Common/interfaces";
|
||||
import jwt from "jsonwebtoken";
|
||||
import got from "got";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
import ClientBotInstance from "./ClientBotInstance";
|
||||
|
||||
export class CMInstance implements CMInterface {
|
||||
friendly?: string;
|
||||
@@ -24,6 +29,7 @@ export class CMInstance implements CMInterface {
|
||||
migrationBlocker?: string
|
||||
host: string;
|
||||
secret: string;
|
||||
invites: string[] = [];
|
||||
|
||||
logger: Logger;
|
||||
logs: LogInfo[] = [];
|
||||
@@ -72,6 +78,7 @@ export class CMInstance implements CMInterface {
|
||||
secret: this.secret,
|
||||
ranMigrations: this.ranMigrations,
|
||||
migrationBlocker: this.migrationBlocker,
|
||||
invites: this.invites,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +93,16 @@ export class CMInstance implements CMInterface {
|
||||
return normalizeUrl(val) == this.normalUrl;
|
||||
}
|
||||
|
||||
getToken() {
|
||||
return jwt.sign({
|
||||
data: {
|
||||
machine: true,
|
||||
},
|
||||
}, this.secret, {
|
||||
expiresIn: '1m'
|
||||
});
|
||||
}
|
||||
|
||||
updateFromHeartbeat = (resp: HeartbeatResponse, otherFriendlies: string[] = []) => {
|
||||
this.operators = resp.operators ?? [];
|
||||
this.operatorDisplay = resp.operatorDisplay ?? '';
|
||||
@@ -101,9 +118,10 @@ export class CMInstance implements CMInterface {
|
||||
}
|
||||
}
|
||||
|
||||
this.subreddits = resp.subreddits;
|
||||
//@ts-ignore
|
||||
this.bots = resp.bots.map(x => ({...x, instance: this}));
|
||||
this.bots = resp.bots.map(x => new ClientBotInstance(x, this));
|
||||
this.subreddits = this.bots.map(x => x.getSubreddits()).flat(3);
|
||||
this.invites = resp.invites;
|
||||
|
||||
}
|
||||
|
||||
checkHeartbeat = async (force = false, otherFriendlies: string[] = []) => {
|
||||
@@ -125,13 +143,7 @@ export class CMInstance implements CMInterface {
|
||||
if (shouldCheck) {
|
||||
this.logger.debug('Starting Heartbeat check');
|
||||
this.lastCheck = dayjs().unix();
|
||||
const machineToken = jwt.sign({
|
||||
data: {
|
||||
machine: true,
|
||||
},
|
||||
}, this.secret, {
|
||||
expiresIn: '1m'
|
||||
});
|
||||
const machineToken = this.getToken();
|
||||
|
||||
try {
|
||||
const resp = await got.get(`${this.normalUrl}/heartbeat`, {
|
||||
|
||||
61
src/Web/Client/ClientBotInstance.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
BotInstance,
|
||||
BotInstanceResponse,
|
||||
CMInstanceInterface,
|
||||
ManagerResponse,
|
||||
NormalizedManagerResponse
|
||||
} from '../Common/interfaces';
|
||||
import {intersect, parseRedditEntity} from "../../util";
|
||||
|
||||
export class ClientBotInstance implements BotInstance {
|
||||
instance: CMInstanceInterface;
|
||||
botName: string;
|
||||
// botLink: string;
|
||||
error?: string | undefined;
|
||||
managers: NormalizedManagerResponse[];
|
||||
nanny?: string | undefined;
|
||||
running: boolean;
|
||||
|
||||
constructor(data: BotInstanceResponse, instance: CMInstanceInterface) {
|
||||
this.instance = instance;
|
||||
this.botName = data.botName;
|
||||
//this.botLink = data.botLink;
|
||||
this.error = data.error;
|
||||
this.managers = data.managers.map(x => ({...x, subredditNormal: parseRedditEntity(x.subreddit).name}));
|
||||
this.nanny = data.nanny;
|
||||
this.running = data.running;
|
||||
}
|
||||
|
||||
getManagerNames(): string[] {
|
||||
return this.managers.map(x => x.name);
|
||||
}
|
||||
|
||||
getSubreddits(normalized = true): string[] {
|
||||
return normalized ? this.managers.map(x => x.subredditNormal) : this.managers.map(x => x.subreddit);
|
||||
}
|
||||
|
||||
getAccessibleSubreddits(user: string, subreddits: string[] = []): string[] {
|
||||
const normalSubs = subreddits.map(x => parseRedditEntity(x).name);
|
||||
return Array.from(new Set([...this.getGuestSubreddits(user), ...intersect(normalSubs, this.getSubreddits())]));
|
||||
}
|
||||
|
||||
getGuestManagers(user: string): NormalizedManagerResponse[] {
|
||||
const louser = user.toLowerCase();
|
||||
return this.managers.filter(x => x.guests.map(y => y.name.toLowerCase()).includes(louser));
|
||||
}
|
||||
|
||||
getGuestSubreddits(user: string): string[] {
|
||||
return this.getGuestManagers(user).map(x => x.subredditNormal);
|
||||
}
|
||||
|
||||
canUserAccessBot(user: string, subreddits: string[] = []) {
|
||||
return this.getAccessibleSubreddits(user, subreddits).length > 0;
|
||||
}
|
||||
|
||||
canUserAccessSubreddit(subreddit: string, user: string, subreddits: string[] = []): boolean {
|
||||
return this.getAccessibleSubreddits(user, subreddits).includes(parseRedditEntity(subreddit).name);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ClientBotInstance;
|
||||
@@ -7,11 +7,8 @@ import {Cache} from "cache-manager";
|
||||
import CacheManagerStore from 'express-session-cache-manager'
|
||||
import {CacheOptions} from "../../Common/interfaces";
|
||||
import {Brackets, DataSource, IsNull, LessThanOrEqual, Repository} from "typeorm";
|
||||
import {DateUtils} from 'typeorm/util/DateUtils';
|
||||
import {ClientSession} from "../../Common/WebEntities/ClientSession";
|
||||
import dayjs from "dayjs";
|
||||
import {Logger} from "winston";
|
||||
import {Invite} from "../../Common/WebEntities/Invite";
|
||||
import {WebSetting} from "../../Common/WebEntities/WebSetting";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
@@ -29,12 +26,6 @@ export type TypeormStoreOptions = Partial<SessionOptions & {
|
||||
interface IWebStorageProvider {
|
||||
createSessionStore(options?: CacheManagerStoreOptions | TypeormStoreOptions): Store
|
||||
|
||||
inviteGet(id: string): Promise<InviteData | undefined>
|
||||
|
||||
inviteDelete(id: string): Promise<void>
|
||||
|
||||
inviteCreate(id: string, data: InviteData): Promise<InviteData>
|
||||
|
||||
getSessionSecret(): Promise<string | undefined>
|
||||
|
||||
setSessionSecret(secret: string): Promise<void>
|
||||
@@ -42,43 +33,25 @@ interface IWebStorageProvider {
|
||||
|
||||
interface StorageProviderOptions {
|
||||
logger: Logger
|
||||
invitesMaxAge?: number
|
||||
loggerLabels?: string[]
|
||||
}
|
||||
|
||||
abstract class StorageProvider implements IWebStorageProvider {
|
||||
|
||||
invitesMaxAge?: number
|
||||
logger: Logger;
|
||||
|
||||
protected constructor(data: StorageProviderOptions) {
|
||||
const {
|
||||
logger,
|
||||
invitesMaxAge,
|
||||
loggerLabels = [],
|
||||
} = data;
|
||||
this.invitesMaxAge = invitesMaxAge;
|
||||
this.logger = logger.child({labels: ['Web', 'Storage', ...loggerLabels]}, mergeArr);
|
||||
}
|
||||
|
||||
protected abstract getInvite(id: string): Promise<InviteData | undefined | null>;
|
||||
|
||||
async inviteGet(id: string) {
|
||||
const data = await this.getInvite(id);
|
||||
if (data === undefined || data === null) {
|
||||
return undefined;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
abstract createSessionStore(options?: CacheManagerStoreOptions | TypeormStoreOptions): Store;
|
||||
|
||||
abstract getSessionSecret(): Promise<string | undefined>;
|
||||
|
||||
abstract inviteCreate(id: string, data: InviteData): Promise<InviteData>;
|
||||
|
||||
abstract inviteDelete(id: string): Promise<void>;
|
||||
|
||||
abstract setSessionSecret(secret: string): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -100,19 +73,6 @@ export class CacheStorageProvider extends StorageProvider {
|
||||
return new CacheManagerStore(this.cache, {prefix: 'sess:'});
|
||||
}
|
||||
|
||||
protected async getInvite(id: string) {
|
||||
return await this.cache.get(`invite:${id}`) as InviteData | undefined | null;
|
||||
}
|
||||
|
||||
async inviteCreate(id: string, data: InviteData): Promise<InviteData> {
|
||||
await this.cache.set(`invite:${id}`, data, {ttl: (this.invitesMaxAge ?? 0) * 1000});
|
||||
return data;
|
||||
}
|
||||
|
||||
async inviteDelete(id: string): Promise<void> {
|
||||
return await this.cache.del(`invite:${id}`);
|
||||
}
|
||||
|
||||
async getSessionSecret() {
|
||||
const val = await this.cache.get(`sessionSecret`);
|
||||
if (val === null || val === undefined) {
|
||||
@@ -130,14 +90,12 @@ export class CacheStorageProvider extends StorageProvider {
|
||||
export class DatabaseStorageProvider extends StorageProvider {
|
||||
|
||||
database: DataSource;
|
||||
inviteRepo: Repository<Invite>;
|
||||
webSettingRepo: Repository<WebSetting>;
|
||||
clientSessionRepo: Repository<ClientSession>
|
||||
|
||||
constructor(data: { database: DataSource } & StorageProviderOptions) {
|
||||
super(data);
|
||||
this.database = data.database;
|
||||
this.inviteRepo = this.database.getRepository(Invite);
|
||||
this.webSettingRepo = this.database.getRepository(WebSetting);
|
||||
this.clientSessionRepo = this.database.getRepository(ClientSession);
|
||||
this.logger.debug('Using DATABASE');
|
||||
@@ -147,26 +105,6 @@ export class DatabaseStorageProvider extends StorageProvider {
|
||||
return new TypeormStore(options).connect(this.clientSessionRepo)
|
||||
}
|
||||
|
||||
protected async getInvite(id: string): Promise<InviteData | undefined | null> {
|
||||
const qb = this.inviteRepo.createQueryBuilder('invite');
|
||||
return await qb
|
||||
.andWhere({id})
|
||||
.andWhere(new Brackets((qb) => {
|
||||
qb.where({_expiresAt: LessThanOrEqual(DateUtils.mixedDateToDatetimeString(dayjs().toDate()))})
|
||||
.orWhere({_expiresAt: IsNull()})
|
||||
})
|
||||
).getOne();
|
||||
}
|
||||
|
||||
async inviteCreate(id: string, data: InviteData): Promise<InviteData> {
|
||||
await this.inviteRepo.save(new Invite({...data, id}));
|
||||
return data;
|
||||
}
|
||||
|
||||
async inviteDelete(id: string): Promise<void> {
|
||||
await this.inviteRepo.delete(id);
|
||||
}
|
||||
|
||||
async getSessionSecret(): Promise<string | undefined> {
|
||||
try {
|
||||
const dbSessionSecret = await this.webSettingRepo.findOneBy({name: 'sessionSecret'});
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
CheckSummary,
|
||||
RunResult,
|
||||
ActionedEvent,
|
||||
ActionResult, RuleResult, EventActivity
|
||||
ActionResult, RuleResult, EventActivity, OperatorConfigWithFileContext
|
||||
} from "../../Common/interfaces";
|
||||
import {
|
||||
buildCachePrefix,
|
||||
@@ -38,7 +38,6 @@ import sharedSession from "express-socket.io-session";
|
||||
import dayjs from "dayjs";
|
||||
import httpProxy from 'http-proxy';
|
||||
import {arrayMiddle, booleanMiddle} from "../Common/middleware";
|
||||
import {BotInstance, CMInstanceInterface} from "../interfaces";
|
||||
import { URL } from "url";
|
||||
import {MESSAGE} from "triple-beam";
|
||||
import Autolinker from "autolinker";
|
||||
@@ -57,6 +56,8 @@ import {MigrationService} from "../../Common/MigrationService";
|
||||
import {RuleResultEntity} from "../../Common/Entities/RuleResultEntity";
|
||||
import {RuleSetResultEntity} from "../../Common/Entities/RuleSetResultEntity";
|
||||
import { PaginationAwareObject } from "../Common/util";
|
||||
import {BotInstance, BotStatusResponse, CMInstanceInterface, InviteData} from "../Common/interfaces";
|
||||
import {open} from "fs/promises";
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
@@ -151,10 +152,10 @@ const availableLevels = ['error', 'warn', 'info', 'verbose', 'debug'];
|
||||
|
||||
let webLogs: LogInfo[] = [];
|
||||
|
||||
const webClient = async (options: OperatorConfig) => {
|
||||
const webClient = async (options: OperatorConfigWithFileContext) => {
|
||||
const {
|
||||
operator: {
|
||||
name,
|
||||
name: operatorName,
|
||||
display,
|
||||
},
|
||||
userAgent: uaFragment,
|
||||
@@ -169,9 +170,6 @@ const webClient = async (options: OperatorConfig) => {
|
||||
port,
|
||||
storage: webStorage = 'database',
|
||||
caching,
|
||||
invites: {
|
||||
maxAge: invitesMaxAge,
|
||||
},
|
||||
session: {
|
||||
secret: sessionSecretFromConfig,
|
||||
maxAge: sessionMaxAge,
|
||||
@@ -179,16 +177,14 @@ const webClient = async (options: OperatorConfig) => {
|
||||
},
|
||||
maxLogs,
|
||||
clients,
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
},
|
||||
credentials,
|
||||
operators = [],
|
||||
},
|
||||
//database
|
||||
} = options;
|
||||
|
||||
let clientCredentials = credentials;
|
||||
|
||||
let sessionSecretSynced = false;
|
||||
|
||||
const userAgent = getUserAgent(`web:contextBot:{VERSION}{FRAG}:dashboard`, uaFragment);
|
||||
@@ -237,7 +233,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
}
|
||||
});
|
||||
|
||||
const storage = webStorage === 'database' ? new DatabaseStorageProvider({database, invitesMaxAge, logger}) : new CacheStorageProvider({...caching, invitesMaxAge, logger});
|
||||
const storage = webStorage === 'database' ? new DatabaseStorageProvider({database, logger}) : new CacheStorageProvider({...caching, logger});
|
||||
|
||||
let sessionSecret: string;
|
||||
if (sessionSecretFromConfig !== undefined) {
|
||||
@@ -297,9 +293,9 @@ const webClient = async (options: OperatorConfig) => {
|
||||
}
|
||||
const client = await ExtendedSnoowrap.fromAuthCode({
|
||||
userAgent,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: redirectUri as string,
|
||||
clientId: clientCredentials.clientId,
|
||||
clientSecret: clientCredentials.clientSecret,
|
||||
redirectUri: clientCredentials.redirectUri as string,
|
||||
code: code as string,
|
||||
});
|
||||
const user = await client.getMe().name as string;
|
||||
@@ -315,7 +311,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
|
||||
let sessionStoreProvider = storage;
|
||||
if(sessionStorage !== webStorage) {
|
||||
sessionStoreProvider = sessionStorage === 'database' ? new DatabaseStorageProvider({database, invitesMaxAge, logger, loggerLabels: ['Session']}) : new CacheStorageProvider({...caching, invitesMaxAge, logger, loggerLabels: ['Session']});
|
||||
sessionStoreProvider = sessionStorage === 'database' ? new DatabaseStorageProvider({database, logger, loggerLabels: ['Session']}) : new CacheStorageProvider({...caching, logger, loggerLabels: ['Session']});
|
||||
}
|
||||
const sessionObj = session({
|
||||
cookie: {
|
||||
@@ -349,10 +345,50 @@ const webClient = async (options: OperatorConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
app.postAsync('/init', async (req, res, next) => {
|
||||
if (clientCredentials.clientId === undefined || clientCredentials.clientSecret === undefined) {
|
||||
const {
|
||||
redirect = '',
|
||||
clientId = '',
|
||||
clientSecret = '',
|
||||
operator = '',
|
||||
} = req.body as any;
|
||||
if (redirect === null || redirect.trim() === '') {
|
||||
return res.status(400).send('redirect cannot be empty');
|
||||
}
|
||||
if (clientId === null || clientId.trim() === '') {
|
||||
return res.status(400).send('clientId cannot be empty');
|
||||
}
|
||||
if (clientSecret === null || clientSecret.trim() === '') {
|
||||
return res.status(400).send('clientSecret cannot be empty');
|
||||
}
|
||||
if(operatorName === undefined) {
|
||||
return res.status(400).send('operator cannot be empty');
|
||||
}
|
||||
options.fileConfig.document.setWebCredentials({redirectUri: redirect.trim(), clientId: clientId.trim(), clientSecret: clientSecret.trim()});
|
||||
if(operators.length === 0 && operator !== '') {
|
||||
options.fileConfig.document.setOperator(parseRedditEntity(operator, 'user').name);
|
||||
}
|
||||
const handle = await open(options.fileConfig.document.location as string, 'w');
|
||||
await handle.writeFile(options.fileConfig.document.toString());
|
||||
await handle.close();
|
||||
|
||||
clientCredentials = {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: redirect
|
||||
}
|
||||
|
||||
return res.status(200).send();
|
||||
} else {
|
||||
return res.status(400).send('Can only do init setup when client credentials do not already exist.');
|
||||
}
|
||||
});
|
||||
|
||||
const scopeMiddle = arrayMiddle(['scope']);
|
||||
const successMiddle = booleanMiddle([{name: 'closeOnSuccess', defaultVal: undefined, required: false}]);
|
||||
app.getAsync('/login', scopeMiddle, successMiddle, async (req, res, next) => {
|
||||
if (redirectUri === undefined) {
|
||||
if (clientCredentials.redirectUri === undefined) {
|
||||
return res.render('error', {error: `No <b>redirectUri</b> was specified through environmental variables or program argument. This must be provided in order to use the web interface.`});
|
||||
}
|
||||
const {query: { scope: reqScopes = [], closeOnSuccess } } = req;
|
||||
@@ -364,10 +400,13 @@ const webClient = async (options: OperatorConfig) => {
|
||||
// @ts-ignore
|
||||
req.session.closeOnSuccess = closeOnSuccess;
|
||||
}
|
||||
if(clientCredentials.clientId === undefined) {
|
||||
return res.render('init', { operators: operators.join(',') });
|
||||
}
|
||||
const authUrl = Snoowrap.getAuthUrl({
|
||||
clientId,
|
||||
clientId: clientCredentials.clientId,
|
||||
scope: scope,
|
||||
redirectUri: redirectUri as string,
|
||||
redirectUri: clientCredentials.redirectUri as string,
|
||||
permanent: false,
|
||||
state: req.session.state,
|
||||
});
|
||||
@@ -394,10 +433,10 @@ const webClient = async (options: OperatorConfig) => {
|
||||
return res.render('error', {error: errContent});
|
||||
}
|
||||
// @ts-ignore
|
||||
const invite = await storage.inviteGet(req.session.inviteId);
|
||||
const invite = req.session.invite as InviteData; //await storage.inviteGet(req.session.inviteId);
|
||||
if(invite === undefined) {
|
||||
// @ts-ignore
|
||||
return res.render('error', {error: `Could not find invite with id ${req.session.inviteId}?? This should happen!`});
|
||||
return res.render('error', {error: `Could not find invite in session?? This should happen!`});
|
||||
}
|
||||
const client = await Snoowrap.fromAuthCode({
|
||||
userAgent,
|
||||
@@ -409,40 +448,36 @@ const webClient = async (options: OperatorConfig) => {
|
||||
// @ts-ignore
|
||||
const user = await client.getMe();
|
||||
const userName = `u/${user.name}`;
|
||||
|
||||
// @ts-ignore
|
||||
await storage.inviteDelete(req.session.inviteId);
|
||||
//await storage.inviteDelete(req.session.inviteId);
|
||||
let data: any = {
|
||||
accessToken: client.accessToken,
|
||||
refreshToken: client.refreshToken,
|
||||
userName,
|
||||
};
|
||||
if(invite.instance !== undefined) {
|
||||
const bot = cmInstances.find(x => x.getName() === invite.instance);
|
||||
if(bot !== undefined) {
|
||||
const botPayload: any = {
|
||||
overwrite: invite.overwrite === true,
|
||||
name: userName,
|
||||
credentials: {
|
||||
reddit: {
|
||||
accessToken: client.accessToken,
|
||||
refreshToken: client.refreshToken,
|
||||
clientId: invite.clientId,
|
||||
clientSecret: invite.clientSecret,
|
||||
}
|
||||
}
|
||||
};
|
||||
if(invite.subreddits !== undefined && invite.subreddits.length > 0) {
|
||||
botPayload.subreddits = {names: invite.subreddits};
|
||||
|
||||
// @ts-ignore
|
||||
const inviteId = invite.id as string;
|
||||
|
||||
// @ts-ignore
|
||||
const botAddResult: any = await addBot(inviteId, {
|
||||
invite: inviteId,
|
||||
credentials: {
|
||||
reddit: {
|
||||
accessToken: client.accessToken,
|
||||
refreshToken: client.refreshToken,
|
||||
clientId: invite.clientId,
|
||||
clientSecret: invite.clientSecret,
|
||||
}
|
||||
const botAddResult: any = await addBot(bot, {name: invite.creator}, botPayload);
|
||||
// stored
|
||||
// success
|
||||
data = {...data, ...botAddResult};
|
||||
// @ts-ignore
|
||||
req.session.destroy();
|
||||
req.logout();
|
||||
}
|
||||
}
|
||||
},
|
||||
name: userName,
|
||||
});
|
||||
data = {...data, ...botAddResult};
|
||||
|
||||
// @ts-ignore
|
||||
req.session.destroy();
|
||||
req.logout();
|
||||
return res.render('callback', data);
|
||||
} else {
|
||||
return next();
|
||||
@@ -503,31 +538,100 @@ const webClient = async (options: OperatorConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
app.getAsync('/auth/helper', helperAuthed, (req, res) => {
|
||||
const createUserToken = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
req.token = createToken(req.instance as CMInstanceInterface, req.user);
|
||||
next();
|
||||
}
|
||||
|
||||
const instanceWithPermissions = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
delete req.session.botId;
|
||||
delete req.session.authBotId;
|
||||
|
||||
const msg = 'Bot does not exist or you do not have permission to access it';
|
||||
const instance = cmInstances.find(x => x.getName() === req.query.instance);
|
||||
if (instance === undefined) {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
|
||||
if (!req.user?.clientData?.webOperator && !req.user?.canAccessInstance(instance)) {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
|
||||
if (req.params.subreddit !== undefined && !req.user?.canAccessSubreddit(instance,req.params.subreddit)) {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
req.instance = instance;
|
||||
req.session.botId = instance.getName();
|
||||
req.session.authBotId = instance.getName();
|
||||
return next();
|
||||
}
|
||||
|
||||
const instancesViewData = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
|
||||
const user = req.user as Express.User;
|
||||
const instance = req.instance as CMInstance;
|
||||
|
||||
const shownInstances = cmInstances.reduce((acc: CMInstance[], curr) => {
|
||||
const isBotOperator = user?.isInstanceOperator(curr);
|
||||
if(user?.clientData?.webOperator) {
|
||||
// @ts-ignore
|
||||
return acc.concat({...curr.getData(), canAccessLocation: true, isOperator: isBotOperator});
|
||||
}
|
||||
if(!isBotOperator && !req.user?.canAccessInstance(curr)) {
|
||||
return acc;
|
||||
}
|
||||
// @ts-ignore
|
||||
return acc.concat({...curr.getData(), canAccessLocation: isBotOperator, isOperator: isBotOperator, botId: curr.getName()});
|
||||
},[]);
|
||||
|
||||
// @ts-ignore
|
||||
req.instancesViewData = {
|
||||
instances: shownInstances,
|
||||
instanceId: instance.getName()
|
||||
};
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
app.getAsync('/auth/helper', helperAuthed, instanceWithPermissions, instancesViewData, (req, res) => {
|
||||
return res.render('helper', {
|
||||
redirectUri,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: clientCredentials.redirectUri,
|
||||
clientId: clientCredentials.clientId,
|
||||
clientSecret: clientCredentials.clientSecret,
|
||||
token: req.isAuthenticated() && req.user?.clientData?.webOperator ? token : undefined,
|
||||
instances: cmInstances.filter(x => req.user?.isInstanceOperator(x)).map(x => x.getName()),
|
||||
// @ts-ignore
|
||||
...req.instancesViewData,
|
||||
});
|
||||
});
|
||||
|
||||
app.getAsync('/auth/invite', async (req, res) => {
|
||||
const {invite: inviteId} = req.query;
|
||||
app.getAsync('/auth/invite/:inviteId', async (req, res) => {
|
||||
const {inviteId} = req.params;
|
||||
|
||||
if(inviteId === undefined) {
|
||||
if (inviteId === undefined) {
|
||||
return res.render('error', {error: '`invite` param is missing from URL'});
|
||||
}
|
||||
const invite = await storage.inviteGet(inviteId as string);
|
||||
if(invite === undefined || invite === null) {
|
||||
|
||||
const cmInstance = cmInstances.find(x => x.invites.includes(inviteId));
|
||||
if (cmInstance === undefined) {
|
||||
return res.render('error', {error: 'Invite with the given id does not exist'});
|
||||
}
|
||||
|
||||
return res.render('invite', {
|
||||
permissions: JSON.stringify(invite.permissions || []),
|
||||
invite: inviteId,
|
||||
});
|
||||
try {
|
||||
const invite = await got.get(`${cmInstance.normalUrl}/invites/${inviteId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cmInstance.getToken()}`,
|
||||
}
|
||||
}).json() as InviteData;
|
||||
|
||||
return res.render('invite', {
|
||||
guests: invite.guests !== undefined && invite.guests !== null && invite.guests.length > 0 ? invite.guests.join(',') : '',
|
||||
permissions: JSON.stringify(invite.permissions || []),
|
||||
invite: inviteId,
|
||||
});
|
||||
} catch (err: any) {
|
||||
cmInstance.logger.error(new ErrorWithCause(`Retrieving invite failed`, {cause: err}));
|
||||
return res.render('error', {error: 'An error occurred while validating your invite and has been logged. Let the person who gave you this invite know! Sorry about that.'})
|
||||
}
|
||||
});
|
||||
|
||||
app.postAsync('/auth/create', helperAuthed, async (req: express.Request, res: express.Response) => {
|
||||
@@ -538,15 +642,15 @@ const webClient = async (options: OperatorConfig) => {
|
||||
redirect: redir,
|
||||
instance,
|
||||
subreddits,
|
||||
code,
|
||||
guests: guestsVal,
|
||||
} = req.body as any;
|
||||
|
||||
const cid = ci || clientId;
|
||||
const cid = ci || clientCredentials.clientId;
|
||||
if(cid === undefined || cid.trim() === '') {
|
||||
return res.status(400).send('clientId is required');
|
||||
}
|
||||
|
||||
const ced = ce || clientSecret;
|
||||
const ced = ce || clientCredentials.clientSecret;
|
||||
if(ced === undefined || ced.trim() === '') {
|
||||
return res.status(400).send('clientSecret is required');
|
||||
}
|
||||
@@ -555,32 +659,74 @@ const webClient = async (options: OperatorConfig) => {
|
||||
return res.status(400).send('redirectUrl is required');
|
||||
}
|
||||
|
||||
const inviteId = code || nanoid(20);
|
||||
await storage.inviteCreate(inviteId, {
|
||||
let guestArr = [];
|
||||
if(typeof guestsVal === 'string') {
|
||||
guestArr = guestsVal.split(',');
|
||||
} else if(Array.isArray(guestsVal)) {
|
||||
guestArr = guestsVal;
|
||||
}
|
||||
guestArr = guestArr.filter(x => x.trim() !== '').map(x => parseRedditEntity(x, 'user').name);
|
||||
|
||||
const inviteData = {
|
||||
permissions,
|
||||
clientId: (ci || clientId).trim(),
|
||||
clientSecret: (ce || clientSecret).trim(),
|
||||
clientId: (ci || clientCredentials.clientId).trim(),
|
||||
clientSecret: (ce || clientCredentials.clientSecret).trim(),
|
||||
redirectUri: redir.trim(),
|
||||
instance,
|
||||
subreddits: subreddits.trim() === '' ? [] : subreddits.split(',').map((x: string) => parseRedditEntity(x).name),
|
||||
creator: (req.user as Express.User).name,
|
||||
});
|
||||
return res.send(inviteId);
|
||||
guests: guestArr.length > 0 ? guestArr : undefined
|
||||
};
|
||||
const cmInstance = cmInstances.find(x => x.friendly === instance);
|
||||
if(cmInstance === undefined) {
|
||||
return res.status(400).send(`No instance found with name "${instance}"`);
|
||||
}
|
||||
|
||||
const token = createToken(cmInstance, req.user);
|
||||
|
||||
try {
|
||||
const resp = await got.post(`${cmInstance.normalUrl}/invites`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
json: inviteData,
|
||||
}).json() as any;
|
||||
cmInstance.invites.push(resp.id);
|
||||
return res.send(resp.id);
|
||||
} catch (err: any) {
|
||||
cmInstance.logger.error(new ErrorWithCause(`Could not create bot invite.`, {cause: err}));
|
||||
return res.status(400).send(`Error while creating invite: ${err.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
app.getAsync('/auth/init', async (req: express.Request, res: express.Response) => {
|
||||
const {invite: inviteId} = req.query;
|
||||
app.getAsync('/auth/init/:inviteId', async (req: express.Request, res: express.Response) => {
|
||||
const { inviteId } = req.params;
|
||||
if(inviteId === undefined) {
|
||||
return res.render('error', {error: '`invite` param is missing from URL'});
|
||||
}
|
||||
const invite = await storage.inviteGet(inviteId as string);
|
||||
if(invite === undefined || invite === null) {
|
||||
|
||||
const cmInstance = cmInstances.find(x => x.invites.includes(inviteId));
|
||||
if (cmInstance === undefined) {
|
||||
return res.render('error', {error: 'Invite with the given id does not exist'});
|
||||
}
|
||||
|
||||
let invite: InviteData;
|
||||
|
||||
try {
|
||||
invite = await got.get(`${cmInstance.normalUrl}/invites/${inviteId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${cmInstance.getToken()}`,
|
||||
}
|
||||
}).json() as InviteData;
|
||||
|
||||
} catch (err: any) {
|
||||
cmInstance.logger.error(new ErrorWithCause(`Retrieving invite failed`, {cause: err}));
|
||||
return res.render('error', {error: 'An error occurred while validating your invite and has been logged. Let the person who gave you this invite know! Sorry about that.'})
|
||||
}
|
||||
|
||||
req.session.state = `bot_${randomId()}`;
|
||||
// @ts-ignore
|
||||
req.session.inviteId = inviteId;
|
||||
req.session.invite = invite;
|
||||
|
||||
const scope = Object.entries(invite.permissions).reduce((acc: string[], curr) => {
|
||||
const [k, v] = curr as unknown as [string, boolean];
|
||||
@@ -620,31 +766,6 @@ const webClient = async (options: OperatorConfig) => {
|
||||
}
|
||||
logger.info(`Web UI started: http://localhost:${port}`, {label: ['Web']});
|
||||
|
||||
const instanceWithPermissions = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
delete req.session.botId;
|
||||
delete req.session.authBotId;
|
||||
|
||||
const msg = 'Bot does not exist or you do not have permission to access it';
|
||||
const instance = cmInstances.find(x => x.getName() === req.query.instance);
|
||||
if (instance === undefined) {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
|
||||
if (!req.user?.clientData?.webOperator && !req.user?.canAccessInstance(instance)) {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
|
||||
if (req.params.subreddit !== undefined && !req.user?.isInstanceOperator(instance) && !req.user?.subreddits.includes(req.params.subreddit)) {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
req.instance = instance;
|
||||
req.session.botId = instance.getName();
|
||||
if(req.user?.canAccessInstance(instance)) {
|
||||
req.session.authBotId = instance.getName();
|
||||
}
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
const botWithPermissions = (required: boolean = false, setDefault: boolean = false) => async (req: express.Request, res: express.Response, next: Function) => {
|
||||
|
||||
@@ -680,7 +801,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
|
||||
if (req.params.subreddit !== undefined && !req.user?.isInstanceOperator(instance) && !req.user?.subreddits.includes(req.params.subreddit)) {
|
||||
if (req.params.subreddit !== undefined && !req.user?.canAccessSubreddit(instance,req.params.subreddit)) {
|
||||
return res.status(404).render('error', {error: msg});
|
||||
}
|
||||
req.bot = botInstance;
|
||||
@@ -689,11 +810,6 @@ const webClient = async (options: OperatorConfig) => {
|
||||
next();
|
||||
}
|
||||
|
||||
const createUserToken = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
req.token = createToken(req.instance as CMInstanceInterface, req.user);
|
||||
next();
|
||||
}
|
||||
|
||||
const defaultSession = (req: express.Request, res: express.Response, next: Function) => {
|
||||
if(req.session.limit === undefined) {
|
||||
req.session.limit = 200;
|
||||
@@ -750,7 +866,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
if(x.operators.includes(user.name)) {
|
||||
return true;
|
||||
}
|
||||
return intersect(user.subreddits, x.subreddits).length > 0;
|
||||
return x.bots.some(y => y.canUserAccessBot(user.name, user.subreddits));
|
||||
});
|
||||
|
||||
if(accessibleInstance === undefined) {
|
||||
@@ -765,13 +881,13 @@ const webClient = async (options: OperatorConfig) => {
|
||||
next();
|
||||
}
|
||||
|
||||
const defaultSubreddit = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
/* const defaultSubreddit = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
if(req.bot !== undefined && req.query.subreddit === undefined) {
|
||||
const firstAccessibleSub = req.bot.subreddits.find(x => req.user?.isInstanceOperator(req.instance) || req.user?.subreddits.includes(x));
|
||||
const firstAccessibleSub = req.bot.managers.find(x => req.user?.isInstanceOperator(req.instance) || req.user?.subreddits.includes(x));
|
||||
req.query.subreddit = firstAccessibleSub;
|
||||
}
|
||||
next();
|
||||
}
|
||||
}*/
|
||||
|
||||
const initHeartbeat = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
if(!init) {
|
||||
@@ -793,19 +909,41 @@ const webClient = async (options: OperatorConfig) => {
|
||||
}
|
||||
|
||||
const migrationRedirect = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
const user = req.user as Express.User;
|
||||
const instance = req.instance as CMInstance;
|
||||
|
||||
if(instance.bots.length === 0 && instance?.ranMigrations === false && instance?.migrationBlocker !== undefined) {
|
||||
|
||||
if(!user.isInstanceOperator(instance)) {
|
||||
return res.render('error-authenticated', {
|
||||
error: `A database migration, which requires manual confirmation by its <strong>Operator</strong>, is required before this CM instance can finish starting up.`,
|
||||
// @ts-ignore
|
||||
...req.instancesViewData
|
||||
})
|
||||
}
|
||||
|
||||
return res.render('migrations', {
|
||||
type: 'app',
|
||||
ranMigrations: instance.ranMigrations,
|
||||
migrationBlocker: instance.migrationBlocker,
|
||||
instance: instance.friendly
|
||||
instance: instance.friendly,
|
||||
// @ts-ignore
|
||||
...req.instancesViewData
|
||||
});
|
||||
}
|
||||
return next();
|
||||
};
|
||||
|
||||
app.getAsync('/', [initHeartbeat, redirectBotsNotAuthed, ensureAuthenticated, defaultSession, defaultInstance, instanceWithPermissions, migrationRedirect, botWithPermissions(false, true), createUserToken], async (req: express.Request, res: express.Response) => {
|
||||
const redirectNoBots = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
const i = req.instance as CMInstance;
|
||||
if (i.bots.length === 0) {
|
||||
// assuming user is doing first-time setup and this is the default localhost bot
|
||||
return res.redirect(`/auth/helper?instance=${i.getName()}`);
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
app.getAsync('/', [initHeartbeat, redirectBotsNotAuthed, ensureAuthenticated, defaultSession, defaultInstance, instanceWithPermissions, instancesViewData, migrationRedirect, redirectNoBots, botWithPermissions(false, true), createUserToken], async (req: express.Request, res: express.Response) => {
|
||||
|
||||
const user = req.user as Express.User;
|
||||
const instance = req.instance as CMInstance;
|
||||
@@ -814,19 +952,6 @@ const webClient = async (options: OperatorConfig) => {
|
||||
const sort = req.session.sort;
|
||||
const level = req.session.level;
|
||||
|
||||
const shownInstances = cmInstances.reduce((acc: CMInstance[], curr) => {
|
||||
const isBotOperator = req.user?.isInstanceOperator(curr);
|
||||
if(user?.clientData?.webOperator) {
|
||||
// @ts-ignore
|
||||
return acc.concat({...curr.getData(), canAccessLocation: true, isOperator: isBotOperator});
|
||||
}
|
||||
if(!isBotOperator && !req.user?.canAccessInstance(curr)) {
|
||||
return acc;
|
||||
}
|
||||
// @ts-ignore
|
||||
return acc.concat({...curr.getData(), canAccessLocation: isBotOperator, isOperator: isBotOperator, botId: curr.getName()});
|
||||
},[]);
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await got.get(`${instance.normalUrl}/status`, {
|
||||
@@ -848,8 +973,8 @@ const webClient = async (options: OperatorConfig) => {
|
||||
refreshClient({host: instance.host, secret: instance.secret});
|
||||
const isOp = req.user?.isInstanceOperator(instance);
|
||||
return res.render('offline', {
|
||||
instances: shownInstances,
|
||||
instanceId: (req.instance as CMInstance).getName(),
|
||||
// @ts-ignore
|
||||
...req.instancesViewData,
|
||||
isOperator: isOp,
|
||||
// @ts-ignore
|
||||
logs: filterLogs((isOp ? instance.logs : instance.logs.filter(x => x.user === undefined || x.user.includes(req.user.name))), {limit, sort, level}),
|
||||
@@ -880,11 +1005,31 @@ const webClient = async (options: OperatorConfig) => {
|
||||
|
||||
const isOp = req.user?.isInstanceOperator(instance);
|
||||
|
||||
// const bots = resp.bots.map((x: BotStatusResponse) => {
|
||||
// return {
|
||||
// ...x,
|
||||
// subreddits: x.subreddits.map(y => {
|
||||
// return {
|
||||
// ...y,
|
||||
// guests: y.guests.map(z => {
|
||||
// const d = z.expiresAt === undefined ? undefined : dayjs(z.expiresAt);
|
||||
// return {
|
||||
// ...z,
|
||||
// relative: d === undefined ? 'Never' : dayjs.duration(d.diff(dayjs())).humanize(),
|
||||
// date: d === undefined ? 'Never' : d.format('YYYY-MM-DD HH:mm:ssZ')
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
// });
|
||||
|
||||
res.render('status', {
|
||||
instances: shownInstances,
|
||||
// @ts-ignore
|
||||
...req.instancesViewData,
|
||||
bots: resp.bots,
|
||||
now: dayjs().add(1, 'minute').format('YYYY-MM-DDTHH:mm'),
|
||||
botId: (req.instance as CMInstance).getName(),
|
||||
instanceId: (req.instance as CMInstance).getName(),
|
||||
isOperator: isOp,
|
||||
system: isOp ? {
|
||||
// @ts-ignore
|
||||
@@ -915,14 +1060,19 @@ const webClient = async (options: OperatorConfig) => {
|
||||
});
|
||||
});
|
||||
|
||||
app.getAsync('/guest', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(true)], async (req: express.Request, res: express.Response) => {
|
||||
const {subreddit} = req.query as any;
|
||||
return res.status(req.user?.isSubredditGuest(req.bot, subreddit) ? 200 : 403).send();
|
||||
});
|
||||
|
||||
app.postAsync('/config', [ensureAuthenticatedApi, defaultSession, instanceWithPermissions, botWithPermissions(true)], async (req: express.Request, res: express.Response) => {
|
||||
const {subreddit} = req.query as any;
|
||||
const {location, data, reason = 'Updated through CM Web', create = false} = req.body as any;
|
||||
|
||||
const client = new ExtendedSnoowrap({
|
||||
userAgent,
|
||||
clientId,
|
||||
clientSecret,
|
||||
clientId: clientCredentials.clientId,
|
||||
clientSecret: clientCredentials.clientSecret,
|
||||
accessToken: req.user?.clientData?.token
|
||||
});
|
||||
|
||||
@@ -1353,14 +1503,18 @@ const webClient = async (options: OperatorConfig) => {
|
||||
}
|
||||
}
|
||||
|
||||
const addBot = async (bot: CMInstanceInterface, userPayload: any, botPayload: any) => {
|
||||
const addBot = async (inviteId: string, botPayload: any) => {
|
||||
|
||||
const cmInstance = cmInstances.find(x => x.invites.includes(inviteId));
|
||||
if(cmInstance === undefined) {
|
||||
return {success: false, error: 'Could not determine CM instance to add bot to based on invite id (invite id was not found)'};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = createToken(bot, userPayload);
|
||||
const resp = await got.post(`${bot.normalUrl}/bot`, {
|
||||
body: JSON.stringify(botPayload),
|
||||
const resp = await got.post(`${cmInstance.normalUrl}/bot`, {
|
||||
json: botPayload,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Authorization': `Bearer ${cmInstance.getToken()}`,
|
||||
}
|
||||
}).json() as object;
|
||||
return {success: true, ...resp};
|
||||
|
||||
@@ -18,6 +18,9 @@ abstract class CMUser<Instance, Bot, SubredditEntity> implements IUser {
|
||||
public abstract accessibleBots(bots: Bot[]): Bot[]
|
||||
public abstract canAccessSubreddit(val: Bot, name: string): boolean;
|
||||
public abstract accessibleSubreddits(bot: Bot): SubredditEntity[]
|
||||
public abstract isSubredditGuest(val: Bot, name: string): boolean;
|
||||
public abstract isSubredditMod(val: Bot, name: string): boolean;
|
||||
public abstract getModeratedSubreddits(val: Bot): SubredditEntity[]
|
||||
}
|
||||
|
||||
export default CMUser;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BotInstance, CMInstanceInterface} from "../../interfaces";
|
||||
import CMUser from "./CMUser";
|
||||
import {intersect, parseRedditEntity} from "../../../util";
|
||||
import {BotInstance, CMInstanceInterface} from "../interfaces";
|
||||
|
||||
class ClientUser extends CMUser<CMInstanceInterface, BotInstance, string> {
|
||||
|
||||
@@ -9,15 +9,15 @@ class ClientUser extends CMUser<CMInstanceInterface, BotInstance, string> {
|
||||
}
|
||||
|
||||
canAccessInstance(val: CMInstanceInterface): boolean {
|
||||
return this.isInstanceOperator(val) || intersect(this.subreddits, val.subreddits.map(x => parseRedditEntity(x).name)).length > 0;
|
||||
return this.isInstanceOperator(val) || val.bots.filter(x => x.canUserAccessBot(this.name, this.subreddits)).length > 0;
|
||||
}
|
||||
|
||||
canAccessBot(val: BotInstance): boolean {
|
||||
return this.isInstanceOperator(val.instance) || intersect(this.subreddits, val.subreddits.map(x => parseRedditEntity(x).name)).length > 0;
|
||||
return this.isInstanceOperator(val.instance) || val.canUserAccessBot(this.name, this.subreddits);
|
||||
}
|
||||
|
||||
canAccessSubreddit(val: BotInstance, name: string): boolean {
|
||||
return this.isInstanceOperator(val.instance) || this.subreddits.map(x => x.toLowerCase()).includes(parseRedditEntity(name).name.toLowerCase());
|
||||
return this.isInstanceOperator(val.instance) || val.canUserAccessSubreddit(name, this.name, this.subreddits);
|
||||
}
|
||||
|
||||
accessibleBots(bots: BotInstance[]): BotInstance[] {
|
||||
@@ -28,12 +28,32 @@ class ClientUser extends CMUser<CMInstanceInterface, BotInstance, string> {
|
||||
if (this.isInstanceOperator(x.instance)) {
|
||||
return true;
|
||||
}
|
||||
return intersect(this.subreddits, x.subreddits.map(y => parseRedditEntity(y).name)).length > 0
|
||||
return x.canUserAccessBot(this.name, this.subreddits);
|
||||
//return intersect(this.subreddits, x.managers.map(y => parseRedditEntity(y).name)).length > 0
|
||||
});
|
||||
}
|
||||
|
||||
accessibleSubreddits(bot: BotInstance): string[] {
|
||||
return this.isInstanceOperator(bot.instance) ? bot.subreddits.map(x => parseRedditEntity(x).name) : intersect(this.subreddits, bot.subreddits.map(x => parseRedditEntity(x).name));
|
||||
return this.isInstanceOperator(bot.instance) ? bot.getSubreddits() : bot.getAccessibleSubreddits(this.name, this.subreddits);
|
||||
}
|
||||
|
||||
isSubredditGuest(val: BotInstance, name: string): boolean {
|
||||
const normalName = parseRedditEntity(name).name;
|
||||
const manager = val.managers.find(x => x.subredditNormal === normalName);
|
||||
if(manager !== undefined) {
|
||||
return manager.guests.some(y => y.name.toLowerCase() === this.name.toLowerCase());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isSubredditMod(val: BotInstance, name: string): boolean {
|
||||
const normalName = parseRedditEntity(name).name;
|
||||
return this.canAccessSubreddit(val, name) && this.subreddits.map(x => parseRedditEntity(name).name).includes(normalName);
|
||||
}
|
||||
|
||||
getModeratedSubreddits(val: BotInstance): string[] {
|
||||
const normalSubs = this.subreddits.map(x => parseRedditEntity(x).name);
|
||||
return val.managers.filter(x => normalSubs.includes(x.subredditNormal)).map(x => x.subredditNormal);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {BotInstance, CMInstanceInterface} from "../../interfaces";
|
||||
import CMUser from "./CMUser";
|
||||
import {intersect, parseRedditEntity} from "../../../util";
|
||||
import {App} from "../../../App";
|
||||
import Bot from "../../../Bot";
|
||||
import {Manager} from "../../../Subreddit/Manager";
|
||||
import {BotInstance, CMInstanceInterface} from "../interfaces";
|
||||
|
||||
class ServerUser extends CMUser<App, Bot, Manager> {
|
||||
|
||||
@@ -16,23 +16,49 @@ class ServerUser extends CMUser<App, Bot, Manager> {
|
||||
}
|
||||
|
||||
canAccessInstance(val: App): boolean {
|
||||
return this.isOperator || val.bots.filter(x => intersect(this.subreddits, x.subManagers.map(y => y.subreddit.display_name))).length > 0;
|
||||
return this.isOperator || val.bots.filter(x => x.canUserAccessBot(this.name, this.subreddits)).length > 0;
|
||||
}
|
||||
|
||||
canAccessBot(val: Bot): boolean {
|
||||
return this.isOperator || intersect(this.subreddits, val.subManagers.map(y => y.subreddit.display_name)).length > 0;
|
||||
return this.isOperator || val.canUserAccessBot(this.name, this.subreddits);
|
||||
}
|
||||
|
||||
accessibleBots(bots: Bot[]): Bot[] {
|
||||
return this.isOperator ? bots : bots.filter(x => intersect(this.subreddits, x.subManagers.map(y => y.subreddit.display_name)).length > 0);
|
||||
return this.isOperator ? bots : bots.filter(x => x.canUserAccessBot(this.name, this.subreddits));
|
||||
}
|
||||
|
||||
canAccessSubreddit(val: Bot, name: string): boolean {
|
||||
return this.isOperator || this.subreddits.includes(parseRedditEntity(name).name) && val.subManagers.some(y => y.subreddit.display_name.toLowerCase() === parseRedditEntity(name).name.toLowerCase());
|
||||
const normalName = parseRedditEntity(name).name;
|
||||
return this.isOperator || this.accessibleSubreddits(val).some(x => x.toNormalizedManager().subredditNormal === normalName);
|
||||
}
|
||||
|
||||
accessibleSubreddits(bot: Bot): Manager[] {
|
||||
return this.isOperator ? bot.subManagers : bot.subManagers.filter(x => intersect(this.subreddits, [x.subreddit.display_name]).length > 0);
|
||||
if(this.isOperator) {
|
||||
return bot.subManagers;
|
||||
}
|
||||
|
||||
const subs = bot.getAccessibleSubreddits(this.name, this.subreddits);
|
||||
return bot.subManagers.filter(x => subs.includes(x.toNormalizedManager().subredditNormal));
|
||||
}
|
||||
|
||||
isSubredditGuest(val: Bot, name: string): boolean {
|
||||
const normalName = parseRedditEntity(name).name;
|
||||
const manager = val.subManagers.find(x => parseRedditEntity(x.subreddit.display_name).name === normalName);
|
||||
if(manager !== undefined) {
|
||||
return manager.toNormalizedManager().guests.some(x => x.name === this.name);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
isSubredditMod(val: Bot, name: string): boolean {
|
||||
const normalName = parseRedditEntity(name).name;
|
||||
return val.subManagers.some(x => parseRedditEntity(x.subreddit.display_name).name === normalName) && this.subreddits.map(x => parseRedditEntity(x).name).some(x => x === normalName);
|
||||
}
|
||||
|
||||
getModeratedSubreddits(val: Bot): Manager[] {
|
||||
const normalSubs = this.subreddits.map(x => parseRedditEntity(x).name);
|
||||
|
||||
return val.subManagers.filter(x => normalSubs.includes(x.subreddit.display_name));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ const sub: SubredditDataResponse = {
|
||||
heartbeatHuman: "-",
|
||||
indicator: "-",
|
||||
logs: [],
|
||||
guests: [],
|
||||
maxWorkers: 0,
|
||||
name: "-",
|
||||
pollingInfo: [],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {RunningState} from "../../Subreddit/Manager";
|
||||
import {LogInfo, ManagerStats} from "../../Common/interfaces";
|
||||
import {BotInstance} from "../interfaces";
|
||||
import {BotConnection, LogInfo, ManagerStats} from "../../Common/interfaces";
|
||||
import {Guest, GuestAll} from "../../Common/Entities/Guest/GuestInterfaces";
|
||||
import {URL} from "url";
|
||||
import {Dayjs} from "dayjs";
|
||||
|
||||
export interface BotStats {
|
||||
startedAtHuman: string,
|
||||
@@ -50,6 +52,7 @@ export interface SubredditDataResponse {
|
||||
heartbeatHuman?: string
|
||||
heartbeat: number
|
||||
retention: string
|
||||
guests: (Guest | GuestAll)[]
|
||||
}
|
||||
|
||||
export interface BotStatusResponse {
|
||||
@@ -77,14 +80,65 @@ export interface IUser {
|
||||
tokenExpiresAt?: number
|
||||
}
|
||||
|
||||
export interface ManagerResponse {
|
||||
name: string,
|
||||
subreddit: string,
|
||||
guests: Guest[]
|
||||
}
|
||||
|
||||
export interface NormalizedManagerResponse extends ManagerResponse {
|
||||
subredditNormal: string
|
||||
}
|
||||
|
||||
|
||||
export interface BotInstanceResponse {
|
||||
botName: string
|
||||
//botLink: string
|
||||
error?: string
|
||||
managers: ManagerResponse[]
|
||||
nanny?: string
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export interface BotInstanceFunctions {
|
||||
getSubreddits: (normalized?: boolean) => string[]
|
||||
getAccessibleSubreddits: (user: string, subreddits: string[]) => string[]
|
||||
getManagerNames: () => string[]
|
||||
getGuestManagers: (user: string) => NormalizedManagerResponse[]
|
||||
getGuestSubreddits: (user: string) => string[]
|
||||
canUserAccessBot: (user: string, subreddits: string[]) => boolean
|
||||
canUserAccessSubreddit: (subreddit: string, user: string, subreddits: string[]) => boolean
|
||||
}
|
||||
|
||||
export interface BotInstance extends BotInstanceResponse, BotInstanceFunctions {
|
||||
managers: NormalizedManagerResponse[]
|
||||
instance: CMInstanceInterface
|
||||
}
|
||||
|
||||
export interface CMInstanceInterface extends BotConnection {
|
||||
friendly?: string
|
||||
operators: string[]
|
||||
operatorDisplay: string
|
||||
url: URL,
|
||||
normalUrl: string,
|
||||
lastCheck?: number
|
||||
online: boolean
|
||||
subreddits: string[]
|
||||
bots: BotInstance[]
|
||||
error?: string
|
||||
ranMigrations: boolean
|
||||
migrationBlocker?: string
|
||||
invites: string[]
|
||||
}
|
||||
|
||||
export interface HeartbeatResponse {
|
||||
ranMigrations: boolean
|
||||
migrationBlocker?: string
|
||||
subreddits: string[]
|
||||
operators: string[]
|
||||
operatorDisplay?: string
|
||||
friendly?: string
|
||||
bots: BotInstance[]
|
||||
bots: BotInstanceResponse[]
|
||||
invites: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -97,4 +151,14 @@ export interface InviteData {
|
||||
redirectUri: string
|
||||
creator: string
|
||||
overwrite?: boolean
|
||||
initialConfig?: string
|
||||
expiresAt?: number | Dayjs
|
||||
guests?: string[]
|
||||
}
|
||||
|
||||
export interface SubredditInviteData {
|
||||
subreddit: string
|
||||
guests?: string[]
|
||||
initialConfig?: string
|
||||
expiresAt?: number | Dayjs
|
||||
}
|
||||
|
||||
@@ -2,12 +2,26 @@ import {Request, Response, NextFunction} from "express";
|
||||
import Bot from "../../Bot";
|
||||
import ServerUser from "../Common/User/ServerUser";
|
||||
|
||||
export const authUserCheck = (userRequired: boolean = true) => async (req: Request, res: Response, next: Function) => {
|
||||
export type AuthEntityType = 'user' | 'operator' | 'machine';
|
||||
|
||||
export const authUserCheck = (allowedEntityTypes: AuthEntityType | AuthEntityType[] = ['user']) => async (req: Request, res: Response, next: Function) => {
|
||||
const types = Array.isArray(allowedEntityTypes) ? allowedEntityTypes : [allowedEntityTypes];
|
||||
|
||||
if (req.isAuthenticated()) {
|
||||
if (userRequired && (req.user as ServerUser).machine) {
|
||||
return res.status(403).send('Must be authenticated as a user to access this route');
|
||||
if(types.length === 0) {
|
||||
return next();
|
||||
}
|
||||
return next();
|
||||
if(types.includes('machine') && (req.user as ServerUser).machine) {
|
||||
return next();
|
||||
}
|
||||
if(types.includes('operator') && req.user.isInstanceOperator(req.botApp)) {
|
||||
return next();
|
||||
}
|
||||
if(types.includes('user') && !(req.user as ServerUser).machine) {
|
||||
return next();
|
||||
}
|
||||
req.logger.error(`User is authenticated but does not sufficient permissions. Required: ${types.join(', ')} | User: ${req.user.name}`);
|
||||
return res.status(403).send('Must be authenticated to access this route');
|
||||
} else {
|
||||
return res.status(401).send('Must be authenticated to access this route');
|
||||
}
|
||||
@@ -38,7 +52,7 @@ export const botRoute = (required = true) => async (req: Request, res: Response,
|
||||
return next();
|
||||
}
|
||||
|
||||
export const subredditRoute = (required = true) => async (req: Request, res: Response, next: Function) => {
|
||||
export const subredditRoute = (required = true, modRequired = false, guestRequired = false) => async (req: Request, res: Response, next: Function) => {
|
||||
|
||||
const bot = req.serverBot;
|
||||
|
||||
@@ -57,7 +71,7 @@ export const subredditRoute = (required = true) => async (req: Request, res: Res
|
||||
return res.status(400).send('Cannot access route for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
|
||||
if (!req.user?.canAccessSubreddit(bot, subreddit)) {
|
||||
if (!req.user?.canAccessSubreddit(bot, subreddit) || (modRequired && !req.user?.isSubredditMod(bot, subreddit)) || (guestRequired && !req.user?.isSubredditGuest(bot, subreddit))) {
|
||||
return res.status(400).send('Cannot access route for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import {Router} from '@awaitjs/express';
|
||||
import {Request, Response} from 'express';
|
||||
import {authUserCheck} from "../../middleware";
|
||||
import {HeartbeatResponse} from "../../../Common/interfaces";
|
||||
import {guestEntityToApiGuest} from "../../../../Common/Entities/Guest/GuestEntity";
|
||||
|
||||
const router = Router();
|
||||
router.use(authUserCheck(false));
|
||||
/*const router = Router();
|
||||
router.use(authUserCheck(['machine']));*/
|
||||
|
||||
interface OperatorData {
|
||||
name: string[]
|
||||
@@ -17,24 +18,26 @@ export const heartbeat = (opData: OperatorData) => {
|
||||
if(req.botApp === undefined) {
|
||||
return res.status(500).send('Application is initializing, try again in a few seconds');
|
||||
}
|
||||
req.botApp.migrationBlocker
|
||||
//req.botApp.migrationBlocker
|
||||
const heartbeatData: HeartbeatResponse = {
|
||||
subreddits: req.botApp.bots.map(y => y.subManagers.map(x => x.subreddit.display_name)).flat(),
|
||||
// @ts-ignore
|
||||
bots: req.botApp.bots.map(x => ({botName: x.botName, subreddits: x.subManagers.map(y => y.displayLabel), running: x.running})),
|
||||
//subreddits: req.botApp.bots.map(y => y.subManagers.map(x => x.subreddit.display_name)).flat(),
|
||||
bots: req.botApp.bots.map(x => ({
|
||||
botName: x.botName as string,
|
||||
managers: x.subManagers.map(y => ({
|
||||
name: y.displayLabel,
|
||||
subreddit: y.subreddit.display_name,
|
||||
guests: y.managerEntity.getGuests().map(x => guestEntityToApiGuest(x)),
|
||||
})),
|
||||
running: x.running,
|
||||
})),
|
||||
operators: opData.name,
|
||||
operatorDisplay: opData.display,
|
||||
friendly: opData.friendly,
|
||||
friendly: req.botApp.friendly,
|
||||
ranMigrations: req.botApp.ranMigrations,
|
||||
migrationBlocker: req.botApp.migrationBlocker,
|
||||
//friendly: req.botApp !== undefined ? req.botApp.botName : undefined,
|
||||
//running: req.botApp !== undefined ? req.botApp.heartBeating : false,
|
||||
//nanny: req.botApp !== undefined ? req.botApp.nannyMode : undefined,
|
||||
//botName: req.botApp !== undefined ? req.botApp.botName : undefined,
|
||||
//botLink: req.botApp !== undefined ? req.botApp.botLink : undefined,
|
||||
//error: req.botApp.error,
|
||||
invites: await req.botApp.getInviteIds()
|
||||
};
|
||||
return res.json(heartbeatData);
|
||||
};
|
||||
return [authUserCheck(false), response];
|
||||
return [authUserCheck(['machine']), response];
|
||||
}
|
||||
|
||||
@@ -5,24 +5,29 @@ import Bot from "../../../../../Bot";
|
||||
import LoggedError from "../../../../../Utils/LoggedError";
|
||||
import {open} from 'fs/promises';
|
||||
import {buildBotConfig} from "../../../../../ConfigBuilder";
|
||||
import {BotInvite} from "../../../../../Common/Entities/BotInvite";
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const addBot = () => {
|
||||
|
||||
const middleware = [
|
||||
authUserCheck(),
|
||||
authUserCheck(['machine','operator']),
|
||||
];
|
||||
|
||||
const response = async (req: Request, res: Response) => {
|
||||
|
||||
if (!req.user?.isInstanceOperator(req.app)) {
|
||||
return res.status(401).send("Must be an Operator to use this route");
|
||||
}
|
||||
|
||||
if (!req.botApp.fileConfig.isWriteable) {
|
||||
return res.status(409).send('Operator config is not writeable');
|
||||
}
|
||||
|
||||
const {overwrite = false, ...botData} = req.body;
|
||||
const {overwrite = false, invite: inviteId, ...botData} = req.body;
|
||||
|
||||
// see if we are adding from invite
|
||||
let invite: BotInvite | undefined;
|
||||
|
||||
if(inviteId !== undefined) {
|
||||
invite = await req.botApp.getInviteById(inviteId);
|
||||
}
|
||||
|
||||
// check if bot is new or overwriting
|
||||
let existingBot = req.botApp.bots.find(x => x.botAccount === botData.name);
|
||||
@@ -40,12 +45,20 @@ const addBot = () => {
|
||||
req.botApp.bots.splice(existingBotIndex, 1);
|
||||
}
|
||||
|
||||
if(invite !== undefined && invite.subreddits !== undefined) {
|
||||
botData.subreddits = {names: invite.subreddits};
|
||||
}
|
||||
|
||||
req.botApp.fileConfig.document.addBot(botData);
|
||||
|
||||
const handle = await open(req.botApp.fileConfig.document.location as string, 'w');
|
||||
await handle.writeFile(req.botApp.fileConfig.document.toString());
|
||||
await handle.close();
|
||||
|
||||
if(invite !== undefined) {
|
||||
await req.botApp.deleteInvite(inviteId);
|
||||
}
|
||||
|
||||
const newBot = new Bot(buildBotConfig(botData, req.botApp.config), req.botApp.logger);
|
||||
req.botApp.bots.push(newBot);
|
||||
let result: any = {stored: true, success: true};
|
||||
@@ -77,6 +90,9 @@ const addBot = () => {
|
||||
req.botApp.logger.error(err);
|
||||
}
|
||||
});
|
||||
if(invite !== undefined && invite.guests !== undefined && invite.guests !== null && invite.guests.length > 0) {
|
||||
await newBot.addGuest(invite.guests, dayjs().add(1, 'day'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
req.botApp.logger.error(`Bot ${newBot.botName} cannot recover from this error and must be re-built`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {Request, Response} from 'express';
|
||||
import {authUserCheck, botRoute, subredditRoute} from "../../../middleware";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import winston from 'winston';
|
||||
import {COMMENT_URL_ID, parseLinkIdentifier, parseRedditThingsFromLink, SUBMISSION_URL_ID} from "../../../../../util";
|
||||
import {
|
||||
COMMENT_URL_ID,
|
||||
parseLinkIdentifier,
|
||||
parseRedditEntity,
|
||||
parseRedditThingsFromLink,
|
||||
SUBMISSION_URL_ID
|
||||
} from "../../../../../util";
|
||||
import {booleanMiddle} from "../../../../Common/middleware";
|
||||
import {Manager} from "../../../../../Subreddit/Manager";
|
||||
import {ActionedEvent} from "../../../../../Common/interfaces";
|
||||
import {CMEvent, CMEvent as ActionedEventEntity} from "../../../../../Common/Entities/CMEvent";
|
||||
import {CMEvent} from "../../../../../Common/Entities/CMEvent";
|
||||
import {nanoid} from "nanoid";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
@@ -16,11 +20,11 @@ import {
|
||||
getFullEventsById,
|
||||
paginateRequest
|
||||
} from "../../../../Common/util";
|
||||
import {filterResultsBuilder} from "../../../../../Utils/typeormUtils";
|
||||
import {Brackets} from "typeorm";
|
||||
import {Activity} from "../../../../../Common/Entities/Activity";
|
||||
import {RedditThing} from "../../../../../Common/Infrastructure/Reddit";
|
||||
import {CMError} from "../../../../../Utils/Errors";
|
||||
import {Guest} from "../../../../../Common/Entities/Guest/GuestInterfaces";
|
||||
import {guestEntitiesToAll, guestEntityToApiGuest} from "../../../../../Common/Entities/Guest/GuestEntity";
|
||||
import {ManagerEntity} from "../../../../../Common/Entities/ManagerEntity";
|
||||
import {AuthorEntity} from "../../../../../Common/Entities/AuthorEntity";
|
||||
|
||||
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
|
||||
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
|
||||
@@ -42,46 +46,13 @@ const configLocation = async (req: Request, res: Response) => {
|
||||
|
||||
export const configLocationRoute = [authUserCheck(), botRoute(), subredditRoute(), configLocation];
|
||||
|
||||
const getInvites = async (req: Request, res: Response) => {
|
||||
|
||||
return res.json(await req.serverBot.cacheManager.getPendingSubredditInvites());
|
||||
const removalReasons = async (req: Request, res: Response) => {
|
||||
const manager = req.manager as Manager;
|
||||
const reasons = await manager.resources.getSubredditRemovalReasons()
|
||||
return res.json(reasons);
|
||||
};
|
||||
|
||||
export const getInvitesRoute = [authUserCheck(), botRoute(), getInvites];
|
||||
|
||||
const addInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {subreddit} = req.body as any;
|
||||
if (subreddit === undefined || subreddit === null || subreddit === '') {
|
||||
return res.status(400).send('subreddit must be defined');
|
||||
}
|
||||
try {
|
||||
await req.serverBot.cacheManager.addPendingSubredditInvite(subreddit);
|
||||
} catch (e: any) {
|
||||
if(e instanceof CMError) {
|
||||
req.logger.warn(e);
|
||||
return res.status(400).send(e.message);
|
||||
} else {
|
||||
req.logger.error(e);
|
||||
return res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
return res.status(200).send();
|
||||
};
|
||||
|
||||
export const addInviteRoute = [authUserCheck(), botRoute(), addInvite];
|
||||
|
||||
const deleteInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {subreddit} = req.query as any;
|
||||
if (subreddit === undefined || subreddit === null || subreddit === '') {
|
||||
return res.status(400).send('subreddit must be defined');
|
||||
}
|
||||
await req.serverBot.cacheManager.deletePendingSubredditInvite(subreddit);
|
||||
return res.status(200).send();
|
||||
};
|
||||
|
||||
export const deleteInviteRoute = [authUserCheck(), botRoute(), deleteInvite];
|
||||
export const removalReasonsRoute = [authUserCheck(), botRoute(), subredditRoute(), removalReasons];
|
||||
|
||||
const actionedEvents = async (req: Request, res: Response) => {
|
||||
|
||||
@@ -227,3 +198,72 @@ const cancelDelayed = async (req: Request, res: Response) => {
|
||||
};
|
||||
|
||||
export const cancelDelayedRoute = [authUserCheck(), botRoute(), subredditRoute(true), cancelDelayed];
|
||||
|
||||
const removeGuestMod = async (req: Request, res: Response) => {
|
||||
|
||||
const {name} = req.query as any;
|
||||
|
||||
const isAll = req.manager === undefined;
|
||||
const managers = (isAll ? req.user?.getModeratedSubreddits(req.serverBot) : [req.manager as Manager]) as Manager[];
|
||||
|
||||
const newGuests = await req.serverBot.removeGuest(name, managers.map(x => x.subreddit.display_name));
|
||||
|
||||
const guests = isAll ? guestEntitiesToAll(newGuests) : Array.from(newGuests.values()).flat(3);
|
||||
|
||||
return res.json(guests);
|
||||
};
|
||||
|
||||
export const removeGuestModRoute = [authUserCheck(), botRoute(), subredditRoute(true, true), removeGuestMod];
|
||||
|
||||
const addGuestMod = async (req: Request, res: Response) => {
|
||||
|
||||
const {name, time} = req.query as any;
|
||||
|
||||
const isAll = req.manager === undefined;
|
||||
const managers = (isAll ? req.user?.getModeratedSubreddits(req.serverBot) : [req.manager as Manager]) as Manager[];
|
||||
|
||||
const expiresAt = dayjs(Number.parseInt(time));
|
||||
|
||||
const newGuests = await req.serverBot.addGuest(name, expiresAt, managers.map(x => x.subreddit.display_name));
|
||||
|
||||
const guests = isAll ? guestEntitiesToAll(newGuests) : Array.from(newGuests.values()).flat(3);
|
||||
|
||||
return res.status(200).json(guests);
|
||||
};
|
||||
|
||||
export const addGuestModRoute = [authUserCheck(), botRoute(), subredditRoute(true, true), addGuestMod];
|
||||
|
||||
const saveGuestWikiEdit = async (req: Request, res: Response) => {
|
||||
const {location, data, reason = 'Updated through CM Web', create = false} = req.body as any;
|
||||
|
||||
try {
|
||||
// @ts-ignore
|
||||
const wiki = await req.manager?.subreddit.getWikiPage(location) as WikiPage;
|
||||
await wiki.edit({
|
||||
text: data,
|
||||
reason: `${reason} by Guest Mod ${req.user?.name}`,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500);
|
||||
return res.send(err.message);
|
||||
}
|
||||
|
||||
if(create) {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await req.manager.subreddit.getWikiPage(location).editSettings({
|
||||
permissionLevel: 2,
|
||||
// don't list this page on r/[subreddit]/wiki/pages
|
||||
listed: false,
|
||||
});
|
||||
} catch (err: any) {
|
||||
res.status(500);
|
||||
return res.send(`Successfully created wiki page for configuration but encountered error while setting visibility. You should manually set the wiki page visibility on reddit. \r\n Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200);
|
||||
return res.send();
|
||||
}
|
||||
|
||||
export const saveGuestWikiEditRoute = [authUserCheck(), botRoute(), subredditRoute(true, false, true), saveGuestWikiEdit];
|
||||
|
||||
54
src/Web/Server/routes/authenticated/user/invites.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {authUserCheck, botRoute} from "../../../middleware";
|
||||
import {Request, Response} from "express";
|
||||
import {CMError} from "../../../../../Utils/Errors";
|
||||
|
||||
const getSubredditInvites = async (req: Request, res: Response) => {
|
||||
|
||||
return res.json(await req.serverBot.cacheManager.getPendingSubredditInvites());
|
||||
};
|
||||
export const getSubredditInvitesRoute = [authUserCheck(), botRoute(), getSubredditInvites];
|
||||
const addSubredditInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {subreddit} = req.body as any;
|
||||
if (subreddit === undefined || subreddit === null || subreddit === '') {
|
||||
return res.status(400).send('subreddit must be defined');
|
||||
}
|
||||
try {
|
||||
await req.serverBot.cacheManager.addPendingSubredditInvite(subreddit);
|
||||
} catch (e: any) {
|
||||
if (e instanceof CMError) {
|
||||
req.logger.warn(e);
|
||||
return res.status(400).send(e.message);
|
||||
} else {
|
||||
req.logger.error(e);
|
||||
return res.status(500).send(e.message);
|
||||
}
|
||||
}
|
||||
return res.status(200).send();
|
||||
};
|
||||
export const addSubredditInviteRoute = [authUserCheck(), botRoute(), addSubredditInvite];
|
||||
const deleteSubredditInvite = async (req: Request, res: Response) => {
|
||||
|
||||
const {subreddit} = req.query as any;
|
||||
if (subreddit === undefined || subreddit === null || subreddit === '') {
|
||||
return res.status(400).send('subreddit must be defined');
|
||||
}
|
||||
await req.serverBot.cacheManager.deletePendingSubredditInvite(subreddit);
|
||||
return res.status(200).send();
|
||||
};
|
||||
export const deleteSubredditInviteRoute = [authUserCheck(), botRoute(), deleteSubredditInvite];
|
||||
|
||||
const getBotInvite = async (req: Request, res: Response) => {
|
||||
const invite = await req.botApp.getInviteById(req.params.id as any);
|
||||
if(invite === undefined) {
|
||||
return res.status(404).send(`Invite with ID ${req.params.id} does not exist`);
|
||||
}
|
||||
return res.json(invite);
|
||||
}
|
||||
export const getBotInviteRoute = [authUserCheck(['machine']), getBotInvite];
|
||||
|
||||
const addBotInvite = async (req: Request, res: Response) => {
|
||||
const invite = await req.botApp.addInvite(req.body);
|
||||
return res.json(invite);
|
||||
}
|
||||
export const addBotInviteRoute = [authUserCheck(['operator']), addBotInvite];
|
||||
@@ -1,7 +1,16 @@
|
||||
import {authUserCheck, botRoute, subredditRoute} from "../../../middleware";
|
||||
import {Request, Response} from "express";
|
||||
import Bot from "../../../../../Bot";
|
||||
import {boolToString, cacheStats, difference, filterLogs, formatNumber, logSortFunc, pollingInfo} from "../../../../../util";
|
||||
import {
|
||||
boolToString,
|
||||
cacheStats,
|
||||
difference,
|
||||
filterLogs,
|
||||
formatNumber,
|
||||
logSortFunc, parseRedditEntity,
|
||||
pollingInfo,
|
||||
symmetricalDifference
|
||||
} from "../../../../../util";
|
||||
import dayjs from "dayjs";
|
||||
import {LogInfo, ResourceStats, RUNNING, STOPPED, SYSTEM} from "../../../../../Common/interfaces";
|
||||
import {Manager} from "../../../../../Subreddit/Manager";
|
||||
@@ -10,6 +19,12 @@ import {opStats} from "../../../../Common/util";
|
||||
import {BotStatusResponse} from "../../../../Common/interfaces";
|
||||
import deepEqual from "fast-deep-equal";
|
||||
import {DispatchedEntity} from "../../../../../Common/Entities/DispatchedEntity";
|
||||
import {
|
||||
guestEntitiesToAll,
|
||||
guestEntityToApiGuest,
|
||||
ManagerGuestEntity
|
||||
} from "../../../../../Common/Entities/Guest/GuestEntity";
|
||||
import {Guest} from "../../../../../Common/Entities/Guest/GuestInterfaces";
|
||||
|
||||
const lastFullResponse: Map<string, Record<string, any>> = new Map();
|
||||
|
||||
@@ -38,25 +53,49 @@ const generateDeltaResponse = (data: Record<string, any>, hash: string, response
|
||||
}
|
||||
const delta: Record<string, any> = {};
|
||||
for(const [k,v] of Object.entries(data)) {
|
||||
if(!deepEqual(v, reference[k])) {
|
||||
// on delayed items delta we will send a different data structure back with just remove/new(add)
|
||||
if(k === 'delayedItems') {
|
||||
switch(k) {
|
||||
case 'delayedItems':
|
||||
// on delayed items delta we will send a different data structure back with just remove/new(add)
|
||||
const refIds = reference[k].map((x: DispatchedEntity) => x.id);
|
||||
const latestIds = v.map((x: DispatchedEntity) => x.id);
|
||||
|
||||
if(symmetricalDifference(refIds, latestIds).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const newIds = Array.from(difference(latestIds, refIds));
|
||||
const newItems = v.filter((x: DispatchedEntity) => newIds.includes(x.id));
|
||||
|
||||
// just need ids that should be removed on frontend
|
||||
const removedItems = Array.from(difference(refIds, latestIds));
|
||||
delta[k] = {new: newItems, removed: removedItems};
|
||||
break;
|
||||
case 'guests':
|
||||
const refNames = reference[k].map((x: Guest) => `${x.name}-${x.expiresAt}`);
|
||||
const latestNames = v.map((x: Guest) => `${x.name}-${x.expiresAt}`);
|
||||
|
||||
} else if(v !== null && typeof v === 'object' && reference[k] !== null && typeof reference[k] === 'object') {
|
||||
// for things like cache/stats we only want to delta changed properties, not the entire object
|
||||
delta[k] = mergeDeepEqual(v, reference[k]);
|
||||
} else {
|
||||
if(symmetricalDifference(refNames, latestNames).length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// const newNames = Array.from(difference(latestNames, refNames));
|
||||
// const newGuestItems = v.filter((x: Guest) => newNames.includes(x.name));
|
||||
//
|
||||
// // just need ids that should be removed on frontend
|
||||
// const removedGuestItems = Array.from(difference(refNames, latestNames));
|
||||
// delta[k] = {new: newGuestItems, removed: removedGuestItems};
|
||||
delta[k] = v;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if(!deepEqual(v, reference[k])) {
|
||||
if(v !== null && typeof v === 'object' && reference[k] !== null && typeof reference[k] === 'object') {
|
||||
// for things like cache/stats we only want to delta changed properties, not the entire object
|
||||
delta[k] = mergeDeepEqual(v, reference[k]);
|
||||
} else {
|
||||
delta[k] = v;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
resp = delta;
|
||||
@@ -78,18 +117,33 @@ const liveStats = () => {
|
||||
const manager = req.manager;
|
||||
const responseType = req.query.type === 'delta' ? 'delta' : 'full';
|
||||
const hash = `${bot.botName}${manager !== undefined ? `-${manager.getDisplay()}` : ''}`;
|
||||
const isOperator = req.user?.isInstanceOperator(bot);
|
||||
|
||||
const userModerated: string[] = (req.user as Express.User).subreddits.map(x => parseRedditEntity(x).name);
|
||||
|
||||
if(manager === undefined) {
|
||||
// getting all
|
||||
const subManagerData: any[] = [];
|
||||
//let managerGuests: ManagerGuestEntity[] = [];
|
||||
for (const m of req.user?.accessibleSubreddits(bot) as Manager[]) {
|
||||
|
||||
const isMod = userModerated.some(x => parseRedditEntity(m.subreddit.display_name).name === x);
|
||||
const isGuest = m.managerEntity.getGuests().some(y => y.author.name === req.user?.name);
|
||||
|
||||
//const guests = await m.managerEntity.getGuests();
|
||||
//managerGuests = managerGuests.concat(guests);
|
||||
|
||||
const sd = {
|
||||
name: m.displayLabel,
|
||||
guests: isOperator || isMod ? m.managerEntity.getGuests().map(x => guestEntityToApiGuest(x)) : [],
|
||||
queuedActivities: m.queue.length(),
|
||||
runningActivities: m.queue.running(),
|
||||
delayedItems: m.getDelayedSummary(),
|
||||
maxWorkers: m.queue.concurrency,
|
||||
subMaxWorkers: m.subMaxWorkers || bot.maxWorkers,
|
||||
globalMaxWorkers: bot.maxWorkers,
|
||||
isMod,
|
||||
isGuest,
|
||||
checks: {
|
||||
submissions: m.submissionChecks === undefined ? 0 : m.submissionChecks.length,
|
||||
comments: m.commentChecks === undefined ? 0 : m.commentChecks.length,
|
||||
@@ -213,6 +267,11 @@ const liveStats = () => {
|
||||
scopes: scopes === null || !Array.isArray(scopes) ? [] : scopes,
|
||||
subMaxWorkers,
|
||||
runningActivities,
|
||||
guests: guestEntitiesToAll(subManagerData.reduce((acc, curr) => {
|
||||
acc.set(curr.name, curr.guests);
|
||||
return acc;
|
||||
}, new Map<string, Guest[]>())),
|
||||
isMod: subManagerData.some(x => x.isMod),
|
||||
queuedActivities,
|
||||
delayedItems,
|
||||
botState: {
|
||||
@@ -277,12 +336,15 @@ const liveStats = () => {
|
||||
}
|
||||
return res.json(respData);
|
||||
} else {
|
||||
const isGuest = manager.managerEntity.getGuests().some(y => y.author.name === req.user?.name);
|
||||
const isMod = userModerated.some(x => parseRedditEntity(manager.subreddit.display_name).name === x);
|
||||
// getting specific subreddit stats
|
||||
const sd = {
|
||||
name: manager.displayLabel,
|
||||
botState: manager.managerState,
|
||||
eventsState: manager.eventsState,
|
||||
queueState: manager.queueState,
|
||||
guests: isOperator || isMod ? manager.managerEntity.getGuests().map(x => guestEntityToApiGuest(x)) : [],
|
||||
indicator: 'gray',
|
||||
permissions: await manager.getModPermissions(),
|
||||
queuedActivities: manager.queue.length(),
|
||||
@@ -293,6 +355,8 @@ const liveStats = () => {
|
||||
globalMaxWorkers: bot.maxWorkers,
|
||||
validConfig: boolToString(manager.validConfigLoaded),
|
||||
configFormat: manager.wikiFormat,
|
||||
isGuest,
|
||||
isMod,
|
||||
dryRun: boolToString(manager.dryRun === true),
|
||||
pollingInfo: manager.pollOptions.length === 0 ? ['nothing :('] : manager.pollOptions.map(pollingInfo),
|
||||
checks: {
|
||||
|
||||
@@ -45,7 +45,6 @@ const logs = () => {
|
||||
|
||||
const userName = req.user?.name as string;
|
||||
const isOperator = req.user?.isInstanceOperator(req.botApp);
|
||||
const realManagers = req.botApp.bots.map(x => req.user?.accessibleSubreddits(x).map(x => x.displayLabel)).flat() as string[];
|
||||
const {level = 'verbose', stream, limit = 200, sort = 'descending', streamObjects = false, formatted: formattedVal = true, transports: transportsVal = false} = req.query;
|
||||
|
||||
const formatted = formattedVal as boolean;
|
||||
@@ -68,8 +67,6 @@ const logs = () => {
|
||||
}
|
||||
}
|
||||
|
||||
//const allReq = req.query.subreddit !== undefined && (req.query.subreddit as string).toLowerCase() === 'all';
|
||||
|
||||
if (stream) {
|
||||
|
||||
const requestedManagers = managers.map(x => x.displayLabel);
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
filterLogBySubreddit, filterLogs,
|
||||
formatNumber,
|
||||
intersect,
|
||||
LogEntry, logSortFunc, parseDurationValToDuration,
|
||||
LogEntry, logSortFunc, parseDurationValToDuration, parseRedditEntity,
|
||||
pollingInfo
|
||||
} from "../../../../../util";
|
||||
import {Manager} from "../../../../../Subreddit/Manager";
|
||||
@@ -17,6 +17,11 @@ import {opStats} from "../../../../Common/util";
|
||||
import {authUserCheck, botRoute, subredditRoute} from "../../../middleware";
|
||||
import Bot from "../../../../../Bot";
|
||||
import {DurationVal} from "../../../../../Common/Infrastructure/Atomic";
|
||||
import {
|
||||
guestEntitiesToAll,
|
||||
guestEntityToApiGuest,
|
||||
ManagerGuestEntity
|
||||
} from "../../../../../Common/Entities/Guest/GuestEntity";
|
||||
|
||||
const status = () => {
|
||||
|
||||
@@ -63,6 +68,7 @@ const status = () => {
|
||||
} = req.query;
|
||||
|
||||
const allReq = req.query.subreddit !== undefined && (req.query.subreddit as string).toLowerCase() === 'all';
|
||||
const userModerated: string[] = (req.user as Express.User).subreddits.map(x => parseRedditEntity(x).name);
|
||||
|
||||
const subManagerData = [];
|
||||
for (const m of req.user?.accessibleSubreddits(bot) as Manager[]) {
|
||||
@@ -120,6 +126,8 @@ const status = () => {
|
||||
startedAtHuman: 'Not Started',
|
||||
delayBy: m.delayBy === undefined ? 'No' : `Delayed by ${m.delayBy} sec`,
|
||||
retention,
|
||||
isGuest: m.managerEntity.getGuests().some(y => y.author.name === req.user?.name),
|
||||
isMod: userModerated.some(x => parseRedditEntity(m.subreddit.display_name).name === x)
|
||||
};
|
||||
// TODO replace indicator data with js on client page
|
||||
let indicator;
|
||||
@@ -255,6 +263,7 @@ const status = () => {
|
||||
subMaxWorkers,
|
||||
runningActivities,
|
||||
queuedActivities,
|
||||
isMod: subManagerData.some(x => x.isMod),
|
||||
delayedItems,
|
||||
botState: {
|
||||
state: RUNNING,
|
||||
|
||||
@@ -16,13 +16,11 @@ import status from './routes/authenticated/user/status';
|
||||
import liveStats from './routes/authenticated/user/liveStats';
|
||||
import {
|
||||
actionedEventsRoute,
|
||||
actionRoute,
|
||||
addInviteRoute,
|
||||
actionRoute, addGuestModRoute,
|
||||
cancelDelayedRoute,
|
||||
configLocationRoute,
|
||||
configRoute,
|
||||
deleteInviteRoute,
|
||||
getInvitesRoute
|
||||
removeGuestModRoute, saveGuestWikiEditRoute, removalReasonsRoute
|
||||
} from "./routes/authenticated/user";
|
||||
import action from "./routes/authenticated/user/action";
|
||||
import {authUserCheck, botRoute} from "./middleware";
|
||||
@@ -37,6 +35,13 @@ import dayjs from "dayjs";
|
||||
import { sleep } from '../../util';
|
||||
import {Invokee} from "../../Common/Infrastructure/Atomic";
|
||||
import {Point} from "@influxdata/influxdb-client";
|
||||
import {
|
||||
addBotInviteRoute,
|
||||
addSubredditInviteRoute,
|
||||
deleteSubredditInviteRoute,
|
||||
getBotInviteRoute,
|
||||
getSubredditInvitesRoute
|
||||
} from "./routes/authenticated/user/invites";
|
||||
|
||||
const server = addAsync(express());
|
||||
server.use(bodyParser.json());
|
||||
@@ -204,6 +209,10 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
|
||||
|
||||
server.getAsync('/config/location', ...configLocationRoute);
|
||||
|
||||
server.postAsync('/config', ...saveGuestWikiEditRoute);
|
||||
|
||||
server.getAsync('/reasons', ...removalReasonsRoute);
|
||||
|
||||
server.getAsync('/events', ...actionedEventsRoute);
|
||||
|
||||
server.getAsync('/action', ...action);
|
||||
@@ -212,14 +221,22 @@ const rcbServer = async function (options: OperatorConfigWithFileContext) {
|
||||
|
||||
server.postAsync('/bot', ...addBot());
|
||||
|
||||
server.getAsync('/bot/invite', ...getInvitesRoute);
|
||||
server.getAsync('/bot/invite', ...getSubredditInvitesRoute);
|
||||
|
||||
server.postAsync('/bot/invite', ...addInviteRoute);
|
||||
server.postAsync('/bot/invite', ...addSubredditInviteRoute);
|
||||
|
||||
server.deleteAsync('/bot/invite', ...deleteInviteRoute);
|
||||
server.deleteAsync('/bot/invite', ...deleteSubredditInviteRoute);
|
||||
|
||||
server.deleteAsync('/delayed', ...cancelDelayedRoute);
|
||||
|
||||
server.deleteAsync('/guests', ...removeGuestModRoute);
|
||||
|
||||
server.postAsync('/guests', ...addGuestModRoute);
|
||||
|
||||
server.getAsync('/invites/:id', ...getBotInviteRoute);
|
||||
|
||||
server.postAsync('/invites', ...addBotInviteRoute);
|
||||
|
||||
app = new App(options);
|
||||
|
||||
const initBot = async (causedBy: Invokee = 'system') => {
|
||||
|
||||
@@ -176,3 +176,17 @@ a {
|
||||
li > ul {
|
||||
padding: revert;
|
||||
}
|
||||
|
||||
.cancellable {
|
||||
display:inline;
|
||||
/* margin-left: 10px*/
|
||||
}
|
||||
|
||||
.smallLi:before {
|
||||
margin-left: -10px;
|
||||
content: ""
|
||||
}
|
||||
|
||||
.introjs-tooltip-title,.introjs-tooltiptext {
|
||||
color: black;
|
||||
}
|
||||
|
||||
255
src/Web/assets/public/statusTour.js
Normal file
@@ -0,0 +1,255 @@
|
||||
let steps = [];
|
||||
|
||||
steps = [
|
||||
{
|
||||
title: 'Welcome to the ContextMod (CM) Dashboard',
|
||||
intro: `
|
||||
<div class="space-y-3"><div>The dashboard allows you to monitor and configure your Bot's behavior for each Subreddit it runs on.</div>
|
||||
<ul class="list-inside list-disc">
|
||||
<li><a href="https://github.com/FoxxMD/context-mod/blob/master/docs/webInterface.md" target="_blank">Dashboard Tips</a></li>
|
||||
<li><a href="https://github.com/FoxxMD/context-mod/tree/master/docs/subreddit/components" target="_blank">Config Docs</a></li>
|
||||
<li><a href="https://github.com/FoxxMD/context-mod/issues" target="_blank">Report Issue</a></li>
|
||||
<li><a href="https://www.reddit.com/r/ContextModBot/" target="_blank">CM Subreddit</a></li>
|
||||
<li><a href="https://discord.gg/YgehbC8pXW" target="_blank">CM Discord</a></li>
|
||||
</ul>
|
||||
</div>`
|
||||
},
|
||||
{
|
||||
element: document.querySelector('#help'),
|
||||
intro: 'If you need a refresher of this guide you can click here to re-run it.'
|
||||
},
|
||||
{
|
||||
element: document.querySelector('#botTabs'),
|
||||
title: 'Bots List',
|
||||
intro: 'All of the bot accounts that moderate a subreddit you also moderate are listed here'
|
||||
}
|
||||
];
|
||||
|
||||
const bot = document.querySelector('#botTabs li span:not([data-bot="system"])');
|
||||
if (bot !== null) {
|
||||
steps.push({
|
||||
element: bot,
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Click on a Bot tab to view all of the Subreddits it is running.</div>
|
||||
</div>`
|
||||
});
|
||||
} else {
|
||||
steps.push({
|
||||
element: document.querySelector('#botTabs'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Once a Bot account has been added it will be visible here.</div>
|
||||
</div>`
|
||||
});
|
||||
}
|
||||
|
||||
if(window.isOperator) {
|
||||
steps.push({
|
||||
element: document.querySelector('#botTabs li:last-child'),
|
||||
title: 'Add A Bot',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Start the invite process for adding a new Bot</div>
|
||||
</div>`
|
||||
});
|
||||
}
|
||||
|
||||
const nonSystemSub = document.querySelector('.sub:not([data-bot="system"])');
|
||||
|
||||
if(nonSystemSub === null) {
|
||||
steps.push({
|
||||
element: document.querySelector('.sub'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>After you have added a Bot with a moderated Subreddit re-run this tour to finish!</div>
|
||||
</div>`
|
||||
});
|
||||
} else {
|
||||
const subTab = document.querySelector('#subredditsTab ul');
|
||||
|
||||
steps.push({
|
||||
element: subTab,
|
||||
title: 'Subreddits List',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Displays all of the Subreddits run by the selected Bot</div>
|
||||
<div>${window.isOperator ? 'As an operator you can see all Subreddits even if you are not a moderator. Otherwise you would only be able to see Subreddits you moderate.' : 'You can only view Subreddits that you are also a moderator of.'}</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
const allSub = document.querySelector('#subredditsTab li span[data-subreddit="All"]');
|
||||
steps.push({
|
||||
element: allSub !== null ? allSub : subTab,
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div><strong>All Subreddits</strong> displays an Overview of all Subreddits you have access to as well as some basic Bot information.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
const notAllSub = document.querySelector('#subredditsTab li span:not([data-subreddit="All"])');
|
||||
steps.push({
|
||||
element: notAllSub !== null ? notAllSub : subTab,
|
||||
title: 'Subreddit',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Clicking on an individual Subreddit will switch to its overview/logs.</div>
|
||||
<div><strong>Please click on this Subreddit now before continuing the tour!</strong></div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
const activeSub = document.querySelector('.sub:not([data-subreddit="All"])');
|
||||
|
||||
steps.push({
|
||||
element: activeSub,
|
||||
position: 'top',
|
||||
title: 'Subreddit View',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Information for the currently selected subreddit from the <strong>Subreddit List</strong> is displayed here.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.overviewContainer'),
|
||||
title: 'Overview',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div><strong>Overview</strong> displays the current state of the Bot on this Subreddit.</div>
|
||||
<div>You may also start/stop/pause the Bot, for this Subreddit from here.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.overviewContainer .pollingInfo'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>When <strong>Events</strong> is <strong>running</strong> the Bot is watching these sources, defined in your configuration, for new Activities in your subreddit.</div>
|
||||
<div>When it sees a new Activity it automatically processes it using the Runs/Checks from its configuration.</div>
|
||||
<div>This is a list of the sources the Bot is watching. Abbreviations:</div>
|
||||
<ul class="list-inside list-disc">
|
||||
<li>UNMODERATED - unmoderated mod queue</li>
|
||||
<li>MODQUEUE - modqueue</li>
|
||||
<li>NEWCOMM - new comments</li>
|
||||
<li>NEWSUB - new submissions</li>
|
||||
</ul>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.configContainer'),
|
||||
title: 'Config',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div><strong>Config</strong> displays information about this Subreddit's configuration.</div>
|
||||
<div>A Subreddit's configuration is what determines how the Bot behaves. The Bot <strong>will not run</strong> if its configuration is empty or invalid.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.configContainer .dryRunLabel'),
|
||||
title: 'Dry Run',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div><strong>Dry Run</strong> status determines if the Bot is running in "pretend" mode or not.</div>
|
||||
<div>In Dry Run mode the Bot will check Activities normally but <strong>will not</strong> run any Actions when triggered.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.configContainer .openConfig'),
|
||||
title: 'Config Editor',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div><strong>View</strong> opens the <strong>Configuration Editor</strong> for this subreddit.</div>
|
||||
<div>You can view/create/edit your Subreddit's configuration from the Editor.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.usageContainer'),
|
||||
title: 'Usage',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Displays statistics about what the Bot has done on your Subreddit.</div>
|
||||
<div><strong>Events</strong> are the number of Comments/Submissions the Bot has checked, in total.</div>
|
||||
<div><strong>Actions</strong> are individual actions the Bot has taken in response to triggered Checks. This is usually things like removals, reporting, commenting, etc...</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.usageContainer .openActioned'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Opens a new page where you can see past Actions the Bot has taken, as well as search by permalink. This is equivalent to <strong>Mod Log</strong> on Reddit.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.runBotOnThing'),
|
||||
title: 'Manually Running the Bot',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>You may <strong>manually run</strong> the Bot on any Activity (Submission/Comment) using its permalink.</div>
|
||||
<div>To be clear -- the Bot automatically runs on new Activities from the Subreddit. This is for when you want to re-run or manually run on an arbitrary Activity.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.runBotOnThing input'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Copy the permalink (URL) for a Submission/Comment and paste it here</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.runBotOnThing a.dryRunCheck'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div><strong>Dry Run</strong> means the bot will check the Activity normally but <strong>will not run Actions.</strong></div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.runBotOnThing a.runCheck'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Otherwise use <strong>Run</strong> to run the Bot normally.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('.logs'),
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>A <strong>real-time</strong> stream of logs for this Subreddit.</div>
|
||||
<div>This shows a detailed stream of events and internal details for what the bot is doing.</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
element: activeSub.querySelector('span.has-tooltip'),
|
||||
title: 'More Help',
|
||||
intro: `
|
||||
<div class="space-y-3">
|
||||
<div>Make sure to hover over any <strong>?</strong> symbols you see as these contain more helpful information!</div>
|
||||
</div>`
|
||||
});
|
||||
|
||||
steps.push({
|
||||
title: 'Good Luck!',
|
||||
intro: `This concludes the tour. Remember you can always click <strong>Tour</strong> at any time to replay this guide. Happy botting!`
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
let intro = introJs().setOptions({
|
||||
steps,
|
||||
})
|
||||
|
||||
document.querySelector('#helpStart').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
intro.start();
|
||||
});
|
||||
@@ -56,6 +56,31 @@
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span id="reasonsWrapper" style="display: none;">
|
||||
|
|
||||
<span class="has-tooltip">
|
||||
<span style="z-index:999; margin-top: 30px;" class='tooltip rounded shadow-lg p-3 bg-gray-100 text-black space-y-2'>
|
||||
<strong>Subreddit Removal Reasons Helper</strong>
|
||||
<div>Copy the <b>ID</b> for use in <span class="font-mono">remove</span> action's <span class="font-mono">removalId</span> field</div>
|
||||
<ul style="user-select: text;" class="list-inside list-disc">
|
||||
</ul>
|
||||
</span>
|
||||
<span class="cursor-help">
|
||||
Removal Reasons
|
||||
<span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
| <input id="configUrl" class="text-black placeholder-gray-500 rounded mx-2" style="min-width:400px;" placeholder="URL of a config to load"/> <a href="#" id="loadConfig">Load</a>
|
||||
<div id="editWrapper" class="my-2">
|
||||
<label style="display: none" for="reason">Edit Reason</label><input id="reason" class="text-black placeholder-gray-500 rounded mr-2" style="min-width:400px;" placeholder="Edit Reason: Updated through CM Web"/>
|
||||
@@ -127,6 +152,7 @@
|
||||
}
|
||||
|
||||
window.canSave = <%= canSave %>;
|
||||
window.isGuest = false;
|
||||
|
||||
if (searchParams.get('subreddit') === null) {
|
||||
document.querySelector('#saveTip').style.display = 'none';
|
||||
@@ -141,6 +167,9 @@
|
||||
const saveLink = document.querySelector('#doSave');
|
||||
saveLink.classList.remove('isDisabled');
|
||||
saveLink.href = '#';
|
||||
if(window.isGuest && !saveLink.innerHTML.includes('guest')) {
|
||||
saveLink.innerHTML = `${saveLink.innerHTML} as Guest`;
|
||||
}
|
||||
document.querySelector('#reason').style.display = 'initial';
|
||||
} else {
|
||||
document.querySelector('#saveTip').classList.add('has-tooltip');
|
||||
@@ -212,7 +241,7 @@
|
||||
payload.reason = reasonVal;
|
||||
}
|
||||
|
||||
fetch(`${document.location.origin}/config${document.location.search}`, {
|
||||
fetch(window.isGuest ? `${document.location.origin}/api/config${document.location.search}` : `${document.location.origin}/config${document.location.search}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
@@ -381,6 +410,50 @@
|
||||
} else {
|
||||
resp.text().then(data => {
|
||||
window.wikiLocation = data;
|
||||
});
|
||||
fetch(`${document.location.origin}/guest${document.location.search}`).then((resp) => {
|
||||
if(resp.ok) {
|
||||
window.canSave = true;
|
||||
window.isGuest = true;
|
||||
window.setSaveStatus();
|
||||
}
|
||||
}).catch((e) => {
|
||||
// do nothing, not a guest
|
||||
});
|
||||
|
||||
// Since we are getting config for a subreddit and (assuming) user is authorized to see config then get subreddit removal reasons and populate helper
|
||||
fetch(`${document.location.origin}/api/reasons${document.location.search}`).then((resp) => {
|
||||
if(resp.ok) {
|
||||
resp.json().then((data) => {
|
||||
document.querySelector('#reasonsWrapper').style.display = 'initial';
|
||||
|
||||
const reasonsList = document.querySelector('#reasonsWrapper ul');
|
||||
if(data.length === 0) {
|
||||
const node = document.createElement("LI");
|
||||
node.appendChild(document.createTextNode('None'));
|
||||
reasonsList.appendChild(node);
|
||||
} else {
|
||||
for(const reason of data) {
|
||||
const node = document.createElement("LI");
|
||||
node.appendChild(document.createTextNode(reason.title));
|
||||
|
||||
const copy = document.createElement('span');
|
||||
copy.classList.add('cursor-pointer', 'float-right');
|
||||
copy.insertAdjacentHTML('beforeend', `<a class="hover:bg-gray-400 no-underline rounded-md py-1 px-3 border" href="">Copy ID <span style="display:inline" class="iconify" data-icon="clarity:copy-to-clipboard-line"></span></a>`);
|
||||
copy.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
navigator.clipboard.writeText(reason.id);
|
||||
});
|
||||
node.appendChild(copy);
|
||||
reasonsList.appendChild(node);
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}).catch((e) => {
|
||||
// just log it
|
||||
console.error('Error occurred while trying to fetch subreddit removal reasons');
|
||||
console.error(e);
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
41
src/Web/assets/views/error-authenticated.ejs
Normal file
@@ -0,0 +1,41 @@
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: 'CM'}) %>
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/header') %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Oops 😬</div>
|
||||
<div class="space-y-3">
|
||||
<div>Something went wrong while processing that last request:</div>
|
||||
<div class="space-y-3"><%- error %></div>
|
||||
<% if(locals.operatorDisplay !== undefined && locals.operatorDisplay !== 'Anonymous') { %>
|
||||
<div>Operated By: <%= operatorDisplay %></div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<script>
|
||||
const instanceSearchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const instance = instanceSearchParams.get('instance');
|
||||
|
||||
document.querySelectorAll(`[data-instance].instanceSelectWrapper`).forEach((el) => {
|
||||
if(el.dataset.instance === instance) {
|
||||
el.classList.add('border-2');
|
||||
el.querySelector('a.instanceSelect').classList.add('pointer-events-none','no-underline','font-bold');
|
||||
} else {
|
||||
el.classList.add('border');
|
||||
el.querySelector('a.instanceSelect').classList.add('font-normal','pointer');
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,7 +2,7 @@
|
||||
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title', {title: ' OAuth Helper'}) %>
|
||||
<%- include('partials/header') %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-gray-600">
|
||||
@@ -65,24 +65,34 @@
|
||||
ease-in-out
|
||||
m-0
|
||||
focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none" aria-label="Default select example">
|
||||
<% instances.forEach(function (name, index){ %>
|
||||
<option selected="<%= index === 0 ? 'true' : 'false' %>" value="<%= name %>"><%= name %></option>
|
||||
<%= name %>
|
||||
<% }) %>
|
||||
<option selected="true" value="<%= instanceId %>"><%= instanceId %></option>
|
||||
<%= instanceId %>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">4. Optionally, restrict to Subreddits</div>
|
||||
<div class="my-2 ml-5">
|
||||
<div class="space-y-3">
|
||||
<div>Specify which subreddits, out of all the subreddits the bot moderates, CM should run on.</div>
|
||||
<div>Subreddits should be seperated with a comma. Leave blank to run on all moderated subreddits</div>
|
||||
<div>Specify which subreddits, out of all the subreddits the bot account already moderates, CM should run on.</div>
|
||||
<div>Subreddits should be seperated with a comma. Leave blank to run on all moderated subreddits.</div>
|
||||
<input id="subreddits" style="max-width:800px; display: block;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2 w-full"
|
||||
placeholder="aSubreddit,aSecondSubreddit,aThirdSubreddit">
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">5. Select permissions</div>
|
||||
<div class="text-lg text-semibold my-3">5. Optionally, specify initial Guest Access</div>
|
||||
<div class="my-2 ml-5">
|
||||
<div class="space-y-3">
|
||||
<div>Specify Reddit users who should be automatically added as <b>Guest Mods</b> to any subreddits found by the bot once authorization is complete.</div>
|
||||
<div>If you are already a moderator on all of the subreddits being added you can skip this step.</div>
|
||||
<div>Adding initial Guest Mods is useful when you (the operator) want to setup configs for subreddits you are not a moderator of. This step reduces friction for onboarding as it eliminates the need for moderators to login to the dashboard and manually add you as a Guest Mod.</div>
|
||||
<input id="guestMods" style="max-width:800px; display: block;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2 w-full"
|
||||
placeholder="RedditUser1,RedditUser2">
|
||||
<div><strong>Note:</strong> The user completing authorization will be able to opt-out of initial Guest Mods <strong>and</strong> change which users will be used. This step is a convenience for autofilling this information for the authorization process.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">6. Select permissions</div>
|
||||
<div class="my-2 ml-5">
|
||||
<div class="space-y-3">
|
||||
<div>These are permissions to allow the bot account to perform these actions, <b>in
|
||||
@@ -222,11 +232,9 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">4. <a id="doAuth" href="">Create Authorization Invite</a>
|
||||
<div class="text-lg text-semibold my-3">7. <a id="doAuth" href="">Create Authorization Invite</a>
|
||||
</div>
|
||||
<div class="ml-5 mb-4">
|
||||
<input id="inviteCode" style="min-width:500px;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2" placeholder="Invite code value to use. Leave blank to generate a random one."/>
|
||||
<div class="space-y-3">
|
||||
<div>A unique link will be generated that you (or someone) will use to authorize a Reddit account with this application.</div>
|
||||
<div id="inviteLink"></div>
|
||||
@@ -238,6 +246,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
<script>
|
||||
if (document.querySelector('#redirectUri').value === '') {
|
||||
document.querySelector('#redirectUri').value = `${document.location.origin}/callback`;
|
||||
@@ -259,10 +268,10 @@
|
||||
redirect: document.querySelector('#redirectUri').value,
|
||||
clientId: document.querySelector('#clientId').value,
|
||||
clientSecret: document.querySelector('#clientSecret').value,
|
||||
code: document.querySelector("#inviteCode").value === '' ? undefined : document.querySelector("#inviteCode").value,
|
||||
permissions,
|
||||
instance: document.querySelector('#instanceSelect').value,
|
||||
subreddits: document.querySelector('#subreddits').value
|
||||
subreddits: document.querySelector('#subreddits').value,
|
||||
guests: document.querySelector('#guestMods').value.split(',')
|
||||
})
|
||||
}).then((resp) => {
|
||||
if(!resp.ok) {
|
||||
@@ -272,9 +281,9 @@
|
||||
});
|
||||
} else {
|
||||
document.querySelector("#errorWrapper").classList.add('hidden');
|
||||
document.querySelector("#inviteCode").value = '';
|
||||
document.querySelector('#subreddits').value = '';
|
||||
resp.text().then(t => {
|
||||
document.querySelector("#inviteLink").innerHTML = `Invite Link: <a class="font-semibold" href="${document.location.origin}/auth/invite?invite=${t}">${document.location.origin}/auth/invite?invite=${t}</a>`;
|
||||
document.querySelector("#inviteLink").innerHTML = `Invite Link: <a class="font-semibold" href="${document.location.origin}/auth/invite/${t}">${document.location.origin}/auth/invite/${t}</a>`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
103
src/Web/assets/views/init.ejs
Normal file
@@ -0,0 +1,103 @@
|
||||
<html lang="en">
|
||||
<%- include('partials/head', {title: 'CM'}) %>
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title', {title: 'First Time Setup'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Hi! Looks like you are setting up ContextMod.</div>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
It looks like you are setting up ContextMod because either CM could not find a configuration
|
||||
or your configuration does not include the <a target="_blank"
|
||||
href="https://github.com/FoxxMD/context-mod/blob/master/docs/operator/configuration.md#minimum-config">minimum
|
||||
configuration</a> needed to login to the dashboard. <br/>
|
||||
If you are sure you already have a configuration then make sure it is in a <a
|
||||
target="_blank"
|
||||
href="https://github.com/FoxxMD/context-mod/blob/master/docs/operator/configuration.md#specify-file-location">default
|
||||
location or you have specified where to find it.</a>
|
||||
</div>
|
||||
<div>
|
||||
If this is your first time setting up CM and you do not have a configuration then proceed to
|
||||
generate your minimum configuration.
|
||||
</div>
|
||||
<div>
|
||||
<strong>Note:</strong> If this is a <a target="_blank"
|
||||
href="https://github.com/FoxxMD/context-mod/blob/master/docs/operator/installation.md#dockerhub">docker
|
||||
installation</a> then verify you have <strong>bound the config directory</strong> or
|
||||
else your configuration will be lost the next time you update CM!
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">Set the information you got from <a target="_blank"
|
||||
href="https://github.com/FoxxMD/context-mod/tree/master/docs/operator#provisioning-a-reddit-client">creating
|
||||
a Reddit client</a>
|
||||
</div>
|
||||
<div class="ml-5 stats" style="max-width: fit-content">
|
||||
<label for="redirectUri" style="margin:auto">Redirect URI</label>
|
||||
<input id="redirectUri" style="min-width:500px;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2"
|
||||
placeholder="http://localhost:8085/callback"/>
|
||||
<label for="clientId" style="margin:auto">Client ID</label>
|
||||
<input id="clientId" style="min-width:500px;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2"
|
||||
placeholder="Client ID">
|
||||
<label for="clientSecret" style="margin:auto">Client Secret</label>
|
||||
<input id="clientSecret" style="min-width:500px; display: block;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2"
|
||||
placeholder="Client Secret">
|
||||
</div>
|
||||
<% if(operators === '') { %>
|
||||
<div class="text-lg text-semibold my-3">Set an Operator</div>
|
||||
<div class="space-y-3">
|
||||
This should be <strong>your Reddit username.</strong> CM will use this to determine who can see the "admin" view for CM once you login with your reddit account.
|
||||
<input id="operator" style="min-width:500px; display: block;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2"
|
||||
placeholder="MyUserName">
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="text-lg text-semibold my-3">7. <a id="doConfig" href="">Write to Config</a></div>
|
||||
<div id="errorWrapper" class="font-semibold hidden">Error: <span id="error"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<script>
|
||||
const operators = '<%= operators %>';
|
||||
if (document.querySelector('#redirectUri').value === '') {
|
||||
document.querySelector('#redirectUri').value = `${document.location.origin}/callback`;
|
||||
}
|
||||
document.querySelector('#doConfig').addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
fetch(`${document.location.origin}/init`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
redirect: document.querySelector('#redirectUri').value,
|
||||
clientId: document.querySelector('#clientId').value,
|
||||
clientSecret: document.querySelector('#clientSecret').value,
|
||||
operator: document.querySelector('#operator').value
|
||||
})
|
||||
}).then((resp) => {
|
||||
if(!resp.ok) {
|
||||
document.querySelector("#errorWrapper").classList.remove('hidden');
|
||||
resp.text().then(t => {
|
||||
document.querySelector("#error").innerHTML = t;
|
||||
});
|
||||
} else {
|
||||
if(operators === '') {
|
||||
document.querySelector("#errorWrapper").classList.remove('hidden');
|
||||
document.querySelector('#errorWrapper').innerHTML = 'Success! Because you have set an Operator you must RESTART CM before changes take affect.';
|
||||
} else {
|
||||
window.location.href = `${document.location.origin}/login`;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -8,7 +8,14 @@
|
||||
<div class="bg-gray-600">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Hi! Looks like you're accepting an invite to authorize an account to run on this ContextMod instance:</div>
|
||||
<div class="text-lg text-semibold my-3">1. Review permissions</div>
|
||||
<div class="text-lg text-semibold my-3">1. Visit this page while <strong>logged in to the Reddit account that will be the bot</strong>
|
||||
</div>
|
||||
<div class="ml-5">
|
||||
<div class="space-y-3">
|
||||
<div>Protip: Login to Reddit in an Incognito session, then open this URL in a new tab.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">2. Review permissions</div>
|
||||
<div class="my-2 ml-5">
|
||||
<div class="space-y-3">
|
||||
<div>These are permissions to allow the bot account to perform these actions, <b>in
|
||||
@@ -140,14 +147,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">2. Login to Reddit with the account that will be the bot
|
||||
</div>
|
||||
<div class="ml-5">
|
||||
<div class="text-lg text-semibold my-3">3. Choose initial Guest Access</div>
|
||||
<div class="my-2 ml-5">
|
||||
<div class="space-y-3">
|
||||
<div>Protip: Login to Reddit in an Incognito session, then open this URL in a new tab.</div>
|
||||
<div><b>Guests</b> are Reddit users who are NOT moderators of your Subreddits but can still access the Bot's dashboard and modify its configuration. They <strong>only</strong> have access to ContextMod -- not your subreddit in general.</div>
|
||||
<div>Guest are useful when a reddit user who is not a moderator of your subreddits is helping setup configurations for your bot. Usually this is the Operator of this CM server.</div>
|
||||
<div>Users specified below will be added to all Subreddits for <strong>24 hours</strong> after which they will be automatically removed. You can manually remove Guest from any/all subreddits at any time from the dashboard.</div>
|
||||
<input id="guestMods" disabled style="max-width:800px; display: block;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2 w-full" value="<%- guests %>">
|
||||
<% if(guests.length > 0) { %>
|
||||
<div><strong>Note:</strong> The user(s) above have been pre-filled by the Operator of this server.</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">3. <a id="doAuth" href="">Authorize the account</a>
|
||||
<div class="text-lg text-semibold my-3">4. <a id="doAuth" href="">Authorize the account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,7 +175,7 @@
|
||||
}
|
||||
document.querySelector('#doAuth').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const url = `${document.location.origin}/auth/init?invite=<%= invite %>`;
|
||||
const url = `${document.location.origin}/auth/init/<%= invite %>`;
|
||||
window.location.href = url;
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%- include('partials/head', {title: 'CM'}) %>
|
||||
<body class="bg-gray-900 text-white font-sans">
|
||||
<div class="min-w-screen min-h-screen">
|
||||
<%- include('partials/title', {title: 'Migrations Required'}) %>
|
||||
<%- include('partials/header') %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-gray-600">
|
||||
@@ -42,6 +42,22 @@
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<script>
|
||||
const instanceSearchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
const instance = instanceSearchParams.get('instance');
|
||||
|
||||
document.querySelectorAll(`[data-instance].instanceSelectWrapper`).forEach((el) => {
|
||||
if(el.dataset.instance === instance) {
|
||||
el.classList.add('border-2');
|
||||
el.querySelector('a.instanceSelect').classList.add('pointer-events-none','no-underline','font-bold');
|
||||
} else {
|
||||
el.classList.add('border');
|
||||
el.querySelector('a.instanceSelect').classList.add('font-normal','pointer');
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
<script>
|
||||
document.querySelector('#run').addEventListener('click', e => {
|
||||
|
||||
@@ -87,7 +87,7 @@
|
||||
node.id = `subInvite-${sub}`;
|
||||
var textNode = document.createTextNode(sub);
|
||||
node.appendChild(textNode);
|
||||
node.insertAdjacentHTML('beforeend', `<a href="" class="removeSub" id="removeSub-${sub}" data-subreddit="${sub}"><span style="display:inline; margin-left: 10px" class="iconify-inline" data-icon="icons8:cancel"></span><a/>`);
|
||||
node.insertAdjacentHTML('beforeend', `<a href="" class="removeSub" id="removeSub-${sub}" data-subreddit="${sub}"><span style="display:inline; margin-left: 10px" class="iconify-inline" data-icon="icons8:cancel"></span></a>`);
|
||||
sl.appendChild(node);
|
||||
document.querySelector(`#removeSub-${sub}`).addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<% if(locals.isOperator === true && locals.instanceId !== undefined) { %>
|
||||
<li class="my-3 px-3">
|
||||
<span class="rounded-md py-2 px-3 border">
|
||||
<a class="font-normal pointer hover:font-bold" href="/auth/helper">
|
||||
<a class="font-normal pointer hover:font-bold" href="/auth/helper?instance=<%= locals.instanceId %>">
|
||||
Add Bot +
|
||||
</a>
|
||||
</span>
|
||||
|
||||
@@ -12,4 +12,5 @@
|
||||
<!-- https://developers.google.com/search/docs/advanced/crawling/block-indexing#meta-tag -->
|
||||
<meta name="robots" content="noindex">
|
||||
<!--icons from https://heroicons.com -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/intro.js/6.0.0/introjs.min.css" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
</head>
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
</ul>
|
||||
<% } %>
|
||||
</div>
|
||||
<% if(locals.showHelp === true) { %>
|
||||
<div id="help" class="flex items-center mr-8 text-sm">
|
||||
<a id="helpStart" href="" >
|
||||
Help
|
||||
</a>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="flex items-center mr-8 text-sm">
|
||||
<a href="https://redditstatus.com" target="_blank">
|
||||
<span>Reddit Status: <span id="redditStatus" class="ml-2"><span class="iconify-inline" data-icon="ep:question-filled"></span></span></span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="space-x-4 pt-2 md:px-5 leading-6 font-semibold bg-gray-800">
|
||||
<div class="container mx-auto">
|
||||
<div id="subredditsTab" class="container mx-auto">
|
||||
<% if(locals.bots !== undefined) { %>
|
||||
<% bots.forEach(function (botData){ %>
|
||||
<ul data-bot="<%= botData.system.name %>" class="inline-flex flex-wrap subreddit nestedTabs">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<%- include('partials/head', {title: undefined}) %>
|
||||
<body class="bg-gray-900 text-white">
|
||||
<div class="min-w-screen min-h-screen font-sans">
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/header', {showHelp: true}) %>
|
||||
<%- include('partials/botsTab') %>
|
||||
<%- include('partials/subredditsTab') %>
|
||||
<div class="container mx-auto">
|
||||
@@ -26,7 +26,7 @@
|
||||
<% bot.subreddits.forEach(function (data){ %>
|
||||
<div class="sub <%= bot.system.running ? '' : 'offline' %>" data-subreddit="<%= data.name %>" data-bot="<%= bot.system.name %>">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3 gap-5">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 overviewContainer">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 bg-gray-700 ">
|
||||
<div class="flex items-center justify-between">
|
||||
<h4>Overview</h4>
|
||||
@@ -261,6 +261,42 @@
|
||||
<span class="cursor-help underline" style="text-decoration-style: dotted"><%= data.scopes.length %></span>
|
||||
</span>
|
||||
<% } else %>
|
||||
<label class="guestsLabel <%= (!isOperator && !data.isMod ? 'hidden' : '')%>">
|
||||
<span class="has-tooltip">
|
||||
<span style="margin-top:55px" class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black space-y-3 p-2 text-left'>
|
||||
<p>Reddit users who are allowed to access your bot even though they are not moderators.</p>
|
||||
<p>Guest can do everything a regular mod can except view/add/remove Guest.</p>
|
||||
<p>Additionally, they can <b>edit the subreddit's config using the bot.</b> If a Guest edits your config their username will be mentioned in the wiki page edit reason.</p>
|
||||
</span>
|
||||
<span>
|
||||
Guests<svg xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4 inline-block cursor-help"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor">
|
||||
<use xlink:href="public/questionsymbol.svg#q" />
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<span class="guests <%= (!isOperator && !data.isMod ? 'hidden' : '')%>" style="margin-left: 5px;">
|
||||
<ul class="list-disc list-inside guestList">
|
||||
<li class="smallLi">None</li>
|
||||
</ul>
|
||||
<div class="guestAdd <%= (!data.isMod ? 'hidden' : '')%> inline-flex items-center mt-1">
|
||||
<div>
|
||||
<input
|
||||
style="width:200px;"
|
||||
class="guestAddName border-gray-50 placeholder-gray-500 rounded mr-1 p-1 text-black"
|
||||
placeholder="userName"/>
|
||||
<input type="datetime-local"
|
||||
class="guestAddTime border-gray-50 placeholder-gray-500 mt-2 mr-2 rounded text-black"
|
||||
value="<%= now %>"
|
||||
min="<%= now %>"/>
|
||||
|
||||
</div>
|
||||
<a href="" class="addGuest">Add</a>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
<% if (data.name !== 'All') { %>
|
||||
<ul class="list-disc list-inside mt-4 pollingInfo">
|
||||
@@ -272,7 +308,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<% if (data.name === 'All') { %>
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 configContainer">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 bg-gray-700 ">
|
||||
<h4>API</h4>
|
||||
</div>
|
||||
@@ -332,7 +368,7 @@
|
||||
</div>
|
||||
<% } %>
|
||||
<% if (data.name !== 'All') { %>
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 configContainer">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-700 ">
|
||||
<h4>Config
|
||||
<span class="has-tooltip">
|
||||
@@ -349,7 +385,7 @@
|
||||
<span class="font-semibold validConfig"><%= data.validConfig %></span>
|
||||
<label>Checks</label>
|
||||
<span><span class="submissionCheckCount"><%= data.checks.submissions %></span> Submission | <span class="commentCheckCount"><%= data.checks.comments %></span> Comment </span>
|
||||
<label>Dry Run</label>
|
||||
<label class="dryRunLabel">Dry Run</label>
|
||||
<span><%= data.dryRun %></span>
|
||||
<label>Updated</label>
|
||||
<span class="has-tooltip">
|
||||
@@ -375,14 +411,14 @@
|
||||
<label>Location</label>
|
||||
<span>
|
||||
<a style="display: inline"
|
||||
href="<%= data.wikiHref %>"><%= data.wikiLocation %></a> | <a style="display: inline" target="_blank"
|
||||
href="<%= data.wikiHref %>"><%= data.wikiLocation %></a> | <a class="openConfig" style="display: inline" target="_blank"
|
||||
href="/config?format=<%= data.configFormat %>&instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>">View</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 ">
|
||||
<div class="bg-white shadow-md rounded my-3 bg-gray-600 usageContainer">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-700 ">
|
||||
<h4>Usage</h4>
|
||||
</div>
|
||||
@@ -417,9 +453,9 @@
|
||||
</span>
|
||||
</div>
|
||||
<% if (data.name !== 'All') { %>
|
||||
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>" style="text-decoration-style: dotted">Actioned Events</a>
|
||||
<a class="openActioned" target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>" style="text-decoration-style: dotted">Actioned Events</a>
|
||||
<% } else { %>
|
||||
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>">Actioned Events</a>
|
||||
<a class="openActioned" target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>">Actioned Events</a>
|
||||
<% } %>
|
||||
</div>
|
||||
<div>
|
||||
@@ -620,7 +656,7 @@
|
||||
</div>
|
||||
<br/>
|
||||
<div class="flex items-center justify-between flex-wrap">
|
||||
<div class="inline-flex items-center">
|
||||
<div class="inline-flex items-center runBotOnThing">
|
||||
<div class="relative" style="width:550px; display: inline-block;">
|
||||
<input data-subreddit="<%= data.name %>"
|
||||
style="width: 100%;"
|
||||
@@ -679,6 +715,9 @@
|
||||
<script>
|
||||
window.sort = 'desc';
|
||||
|
||||
const isOperator = <%= isOperator %>;
|
||||
window.isOperator = <%= isOperator %>;
|
||||
|
||||
document.querySelectorAll('.action').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
@@ -726,10 +765,10 @@
|
||||
const isDryun = e.target.classList.contains('dryRunCheck');
|
||||
|
||||
const subSection = e.target.closest('div.sub');
|
||||
bot = subSection.dataset.bot;
|
||||
const botSection = subSection.dataset.bot;
|
||||
const url = urlInput.value;
|
||||
|
||||
const fetchUrl = `/api/check?instance=<%= instanceId %>&bot=${bot}&url=${url}&dryRun=${isDryun ? 1 : 0}&subreddit=${subreddit}&delayOption=${delayOpt}`;
|
||||
const fetchUrl = `/api/check?instance=<%= instanceId %>&bot=${botSection}&url=${url}&dryRun=${isDryun ? 1 : 0}&subreddit=${subreddit}&delayOption=${delayOpt}`;
|
||||
fetch(fetchUrl);
|
||||
|
||||
urlInput.value = '';
|
||||
@@ -1006,10 +1045,10 @@
|
||||
const reader = res.getReader();
|
||||
let keepReading = true;
|
||||
while(keepReading) {
|
||||
const {done, value} = await reader.read();
|
||||
const {done, value, ...rest} = await reader.read();
|
||||
if(done) {
|
||||
keepReading = false;
|
||||
console.debug('done');
|
||||
console.debug(`${bot}.${sub} log stream reader signalled it is done`);
|
||||
}
|
||||
if(value) {
|
||||
//console.log(`((Logged For ${bot} ${sub})) ${value.message}`);
|
||||
@@ -1073,6 +1112,7 @@
|
||||
running
|
||||
} = {},
|
||||
delayedItems,
|
||||
guests,
|
||||
runningActivities,
|
||||
queuedActivities,
|
||||
permissions,
|
||||
@@ -1188,6 +1228,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
if(guests !== undefined) {
|
||||
renderGuestMods(bot, sub, guests);
|
||||
}
|
||||
|
||||
if(eventsCheckedTotal !== undefined) {
|
||||
el.querySelector('.allStats .eventsCount').innerHTML = resp.stats.historical.eventsCheckedTotal;
|
||||
}
|
||||
@@ -1271,6 +1315,101 @@
|
||||
});
|
||||
}
|
||||
|
||||
function removeGuestMod(bot, subredditStr, name) {
|
||||
const subs = subredditStr.split(',');
|
||||
fetch(`/api/guests?instance=<%= instanceId %>&bot=${bot}&subreddit=${subs[0]}&name=${name}`, {
|
||||
method: 'DELETE'
|
||||
}).then((resp) => {
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Response was not OK from ${subs[0]} remove guest ${id}${name}`)
|
||||
} else {
|
||||
return resp.json();
|
||||
}
|
||||
}).then((data) => {
|
||||
renderGuestMods(bot, subs[0], data);
|
||||
});
|
||||
}
|
||||
|
||||
function addGuestMod(bot, subredditStr, name, time) {
|
||||
const subs = subredditStr.split(',');
|
||||
fetch(`/api/guests?instance=<%= instanceId %>&bot=${bot}&subreddit=${subs[0]}&time=${time}&name=${name}`, {
|
||||
method: 'POST'
|
||||
}).then((resp) => {
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Response was not OK from ${subs[0]} add guest ${name}`);
|
||||
} else {
|
||||
return resp.json();
|
||||
}
|
||||
}).then((data) => {
|
||||
renderGuestMods(bot, subs[0], data);
|
||||
document.querySelector(`[data-bot="${bot}"][data-subreddit="${subs[0]}"] .guestAddName`).value = '';
|
||||
//document.querySelector(`[data-bot="${bot}"][data-subreddit="${subs[0]}"] .guestAddTime`).value = dayjs().add(1, 'minutes').format('YYYY-MM-DDTHH:mm');
|
||||
});
|
||||
}
|
||||
|
||||
function renderGuestMods(bot, sub, data) {
|
||||
let el;
|
||||
let isAll = sub.toLowerCase() === 'all';
|
||||
if(isAll) {
|
||||
// got all
|
||||
el = document.querySelector(`[data-subreddit="All"][data-bot="${bot}"] .guestList`);
|
||||
} else {
|
||||
// got subreddit
|
||||
el = document.querySelector(`[data-bot="${bot}"][data-subreddit="${sub}"] .guestList`);
|
||||
}
|
||||
|
||||
const now = dayjs();
|
||||
|
||||
el.innerHTML = '';
|
||||
if(data.length === 0) {
|
||||
const node = document.createElement("LI");
|
||||
node.classList.add('smallLi');
|
||||
node.appendChild(document.createTextNode('None'));
|
||||
el.appendChild(node);
|
||||
} else {
|
||||
for(const g of data) {
|
||||
const node = document.createElement("LI");
|
||||
node.classList.add('smallLi');
|
||||
let relTime = g.expiresAt === undefined ? 'Never' : dayjs.duration(dayjs(g.expiresAt).diff(now)).humanize();
|
||||
let guestText = g.name;
|
||||
if(isAll) {
|
||||
guestText += ` (${g.subreddits.length} Subs, at least ${relTime})`;
|
||||
} else {
|
||||
guestText += ` (${relTime})`;
|
||||
}
|
||||
node.appendChild(document.createTextNode(guestText));
|
||||
node.insertAdjacentHTML('beforeend', `<a href="" class="remove ml-1" data-name="${g.name}"><span class="cancellable iconify-inline" data-icon="icons8:cancel"></span></a>`);
|
||||
node.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
removeGuestMod(bot, sub, g.name);
|
||||
});
|
||||
el.appendChild(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll('.addGuest').forEach(elm => {
|
||||
elm.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const el = e.target;
|
||||
const parent = el.closest('.sub');
|
||||
const sub = parent.dataset.subreddit;
|
||||
const bot = parent.dataset.bot;
|
||||
|
||||
const userEl = el.parentElement.querySelector('input.guestAddName');
|
||||
const timeEl = el.parentElement.querySelector('input.guestAddTime');
|
||||
|
||||
const d = dayjs(timeEl.value);
|
||||
// don't allow users to set a time before now
|
||||
const time = d.isBefore(dayjs()) ? dayjs().add(1, 'minute').valueOf() : d.valueOf();
|
||||
const user = userEl.value;
|
||||
|
||||
console.log(`Adding ${user} expiring at ${time} to ${bot}.${sub}`);
|
||||
|
||||
addGuestMod(bot, sub, user, time)
|
||||
});
|
||||
});
|
||||
|
||||
function getLiveStats(bot, sub, responseType = 'full') {
|
||||
console.debug(`Getting live stats for ${bot} ${sub}`)
|
||||
return fetch(`/api/liveStats?instance=<%= instanceId %>&bot=${bot}&subreddit=${sub}&type=${responseType}`)
|
||||
@@ -1306,7 +1445,7 @@
|
||||
}
|
||||
|
||||
recentlySeen.forEach((value, key) => {
|
||||
const {timeout, liveStatsInt, ...rest} = value;
|
||||
const {timeout, liveStatsInt,...rest} = value;
|
||||
if(key === identifier && timeout !== undefined) {
|
||||
|
||||
console.debug(`${key} Clearing unfocused timeout on own already set`);
|
||||
@@ -1335,7 +1474,7 @@
|
||||
const {controller} = val;
|
||||
console.debug(`${k} 15 second unfocused timeout expired, stopping log streaming`);
|
||||
if(controller !== undefined) {
|
||||
console.debug('Stopping logs');
|
||||
console.debug(`${k} Stopping logs`);
|
||||
controller.abort();
|
||||
}
|
||||
recentlySeen.delete(k);
|
||||
@@ -1523,5 +1662,7 @@
|
||||
document.body.classList.remove('connected');
|
||||
});
|
||||
</script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/intro.js/6.0.0/intro.min.js" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
|
||||
<script src="/public/statusTour.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { URL } from "url";
|
||||
import {BotConnection} from "../Common/interfaces";
|
||||
import {Logger} from "winston";
|
||||
|
||||
export interface BotInstance {
|
||||
botName: string
|
||||
botLink: string
|
||||
error?: string
|
||||
subreddits: string[]
|
||||
nanny?: string
|
||||
running: boolean
|
||||
instance: CMInstanceInterface
|
||||
}
|
||||
|
||||
export interface CMInstanceInterface extends BotConnection {
|
||||
friendly?: string
|
||||
operators: string[]
|
||||
operatorDisplay: string
|
||||
url: URL,
|
||||
normalUrl: string,
|
||||
lastCheck?: number
|
||||
online: boolean
|
||||
subreddits: string[]
|
||||
bots: BotInstance[]
|
||||
error?: string
|
||||
ranMigrations: boolean
|
||||
migrationBlocker?: string
|
||||
}
|
||||
2
src/Web/types/express/index.d.ts
vendored
@@ -1,8 +1,8 @@
|
||||
import {App} from "../../../App";
|
||||
import Bot from "../../../Bot";
|
||||
import {BotInstance, CMInstanceInterface} from "../../interfaces";
|
||||
import {Manager} from "../../../Subreddit/Manager";
|
||||
import CMUser from "../../Common/User/CMUser";
|
||||
import {BotInstance, CMInstanceInterface} from "../../Common/interfaces";
|
||||
|
||||
declare global {
|
||||
declare namespace Express {
|
||||
|
||||
@@ -76,7 +76,7 @@ const program = new Command();
|
||||
} = config;
|
||||
try {
|
||||
if(mode === 'all' || mode === 'client') {
|
||||
await clientServer(config);
|
||||
await clientServer({...config, fileConfig});
|
||||
}
|
||||
if(mode === 'all' || mode === 'server') {
|
||||
await apiServer({...config, fileConfig});
|
||||
|
||||
20
src/util.ts
@@ -74,7 +74,7 @@ import {
|
||||
ActivitySourceTypes,
|
||||
CacheProvider,
|
||||
ConfigFormat,
|
||||
DurationVal, ExternalUrlContext,
|
||||
DurationVal, ExternalUrlContext, ImageHashCacheData,
|
||||
ModUserNoteLabel,
|
||||
modUserNoteLabels,
|
||||
RedditEntity,
|
||||
@@ -116,6 +116,7 @@ import {
|
||||
} from "./Common/Infrastructure/ActivityWindow";
|
||||
import {RunnableBaseJson} from "./Common/Infrastructure/Runnable";
|
||||
import Snoowrap from "snoowrap";
|
||||
import { uniqueNamesGenerator, adjectives, colors, animals } from 'unique-names-generator';
|
||||
|
||||
|
||||
//import {ResembleSingleCallbackComparisonResult} from "resemblejs";
|
||||
@@ -2830,6 +2831,11 @@ export const resolvePath = (pathVal: string, relativeRoot: string) => {
|
||||
return pathUtil.resolve(relativeRoot, pathVal);
|
||||
}
|
||||
|
||||
export const getExtension = (pathVal: string) => {
|
||||
const pathInfo = pathUtil.parse(pathVal);
|
||||
return pathInfo.ext;
|
||||
}
|
||||
|
||||
export const resolvePathFromEnvWithRelative = (pathVal: any, relativeRoot: string, defaultVal?: string) => {
|
||||
if (pathVal === undefined || pathVal === null) {
|
||||
return defaultVal;
|
||||
@@ -2938,3 +2944,15 @@ export function partition<T>(array: T[], callback: (element: T, index: number, a
|
||||
}, [[], []]
|
||||
);
|
||||
}
|
||||
|
||||
export const generateRandomName = () => {
|
||||
return uniqueNamesGenerator({
|
||||
dictionaries: [colors, adjectives, animals],
|
||||
style: 'capital',
|
||||
separator: ''
|
||||
});
|
||||
}
|
||||
|
||||
export const asStrongImageHashCache = (data: ImageHashCacheData): data is Required<ImageHashCacheData> => {
|
||||
return data.original !== undefined && data.flipped !== undefined;
|
||||
}
|
||||
|
||||
1
tests/assets/nonImage.txt
Normal file
@@ -0,0 +1 @@
|
||||
I am not an image
|
||||
BIN
tests/assets/rick-border.jpg
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
tests/assets/rick-copy.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
tests/assets/rick-flipped.jpg
Normal file
|
After Width: | Height: | Size: 250 KiB |
BIN
tests/assets/rick-original.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
tests/assets/rick-ratio.jpg
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
tests/assets/rick-saturated.jpg
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
tests/assets/rick-smaller.jpg
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
tests/assets/rick-whitebg.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
tests/assets/star-inside.png
Normal file
|
After Width: | Height: | Size: 809 KiB |
BIN
tests/assets/star-transparent.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
tests/assets/tran-selection.jpg
Normal file
|
After Width: | Height: | Size: 45 KiB |
BIN
tests/assets/tran.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
182
tests/imageProcessing.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import {describe, it} from 'mocha';
|
||||
import chai from 'chai';
|
||||
import chaiAsPromised from 'chai-as-promised';
|
||||
import express, {Request, Response} from "express";
|
||||
import {resolvePath} from "../src/util";
|
||||
import {pathToFileURL, URL} from "url";
|
||||
import ImageData from "../src/Common/ImageData";
|
||||
import leven from "leven";
|
||||
|
||||
chai.use(chaiAsPromised);
|
||||
|
||||
const assert = chai.assert;
|
||||
|
||||
let app = express();
|
||||
app.use('/assets', express.static(`${__dirname}/assets`));
|
||||
|
||||
const rickOriginalFile = pathToFileURL(resolvePath('./tests/assets/rick-original.jpg', './'));
|
||||
const rickCopyFile = pathToFileURL(resolvePath('./tests/assets/rick-copy.jpg', './'));
|
||||
const rickSmallerFile = pathToFileURL(resolvePath('./tests/assets/rick-smaller.jpg', './'));
|
||||
const rickBorderedFile = pathToFileURL(resolvePath('./tests/assets/rick-border.jpg', './'));
|
||||
const rickFlippedFile = pathToFileURL(resolvePath('./tests/assets/rick-flipped.jpg', './'));
|
||||
const rickWhiteBG = pathToFileURL(resolvePath('./tests/assets/rick-whitebg.jpg', './'));
|
||||
const rickRatio = pathToFileURL(resolvePath('./tests/assets/rick-ratio.jpg', './'));
|
||||
const rickSaturation = pathToFileURL(resolvePath('./tests/assets/rick-saturated.jpg', './'));
|
||||
|
||||
describe('Image Resource Parsing', function () {
|
||||
|
||||
before(() => {
|
||||
// @ts-ignore
|
||||
app.server = app.listen(5999);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
// @ts-ignore
|
||||
app.server.close();
|
||||
});
|
||||
|
||||
it('Handles local resource', async function () {
|
||||
const local = new ImageData({
|
||||
path: rickOriginalFile
|
||||
});
|
||||
await assert.isFulfilled(local.sharp());
|
||||
assert.exists(local.width);
|
||||
});
|
||||
|
||||
it('Handles remote resource', async function () {
|
||||
const local = new ImageData({
|
||||
path: new URL('http://localhost:5999/assets/rick-original.jpg')
|
||||
});
|
||||
await assert.isFulfilled(local.sharp());
|
||||
assert.exists(local.width);
|
||||
});
|
||||
|
||||
it('Throws when remote resource extension is not a known image type', async function () {
|
||||
assert.throws(() => {
|
||||
const local = new ImageData({
|
||||
path: new URL('http://localhost:5999/assets/nonImage.txt')
|
||||
});
|
||||
})
|
||||
});
|
||||
|
||||
it('Throws when remote resource is not an image', async function () {
|
||||
const local = new ImageData({
|
||||
path: new URL('http://localhost:5999/assets/nonImage.txt')
|
||||
}, true);
|
||||
|
||||
await assert.isRejected(local.sharp());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Image Normalization', function () {
|
||||
|
||||
it('Removes borders', async function () {
|
||||
const original = new ImageData({
|
||||
path: rickOriginalFile
|
||||
});
|
||||
await original.sharp();
|
||||
|
||||
const bordered = new ImageData({
|
||||
path: rickBorderedFile
|
||||
});
|
||||
await bordered.sharp();
|
||||
|
||||
assert.equal(original.width, bordered.width);
|
||||
assert.equal(original.height, bordered.height);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hash Comparisons', function () {
|
||||
|
||||
const original = new ImageData({
|
||||
path: rickOriginalFile
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
await original.hash(32);
|
||||
});
|
||||
|
||||
it('Detects identical images as the same', async function () {
|
||||
|
||||
const compareImg = new ImageData({
|
||||
path: rickCopyFile
|
||||
});
|
||||
await compareImg.hash(32);
|
||||
|
||||
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
|
||||
const diffNormal = (distanceNormal/original.hashResult.length)*100;
|
||||
|
||||
assert.equal(diffNormal, 0);
|
||||
});
|
||||
|
||||
it('Detects images with only saturation differences as the same', async function () {
|
||||
|
||||
const compareImg = new ImageData({
|
||||
path: rickSaturation
|
||||
});
|
||||
await compareImg.hash(32);
|
||||
|
||||
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
|
||||
const diffNormal = (distanceNormal/original.hashResult.length)*100;
|
||||
|
||||
assert.isAtMost(diffNormal, 4);
|
||||
});
|
||||
|
||||
it('Detects images with different resolutions as the same', async function () {
|
||||
|
||||
const compareImg = new ImageData({
|
||||
path: rickSmallerFile
|
||||
});
|
||||
await compareImg.hash(32);
|
||||
|
||||
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
|
||||
const diffNormal = (distanceNormal/original.hashResult.length)*100;
|
||||
|
||||
assert.equal(diffNormal, 0);
|
||||
});
|
||||
|
||||
it('Detects flipped versions as the same', async function () {
|
||||
|
||||
const flipped = new ImageData({
|
||||
path: rickFlippedFile
|
||||
});
|
||||
await flipped.hash(32);
|
||||
|
||||
const distanceNormal = leven(original.hashResult, flipped.hashResult);
|
||||
const diffNormal = (distanceNormal/original.hashResult.length)*100;
|
||||
|
||||
assert.isAtLeast(diffNormal, 50);
|
||||
|
||||
const distanceFlipped = leven(original.hashResult, flipped.hashResultFlipped);
|
||||
const diffFlipped = (distanceFlipped/original.hashResult.length)*100;
|
||||
|
||||
assert.isAtMost(diffFlipped, 4);
|
||||
});
|
||||
|
||||
it('Detects images with minor ratio differences as the same', async function () {
|
||||
|
||||
const compareImg = new ImageData({
|
||||
path: rickRatio
|
||||
});
|
||||
await compareImg.hash(32);
|
||||
|
||||
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
|
||||
const diffNormal = (distanceNormal/original.hashResult.length)*100;
|
||||
|
||||
assert.isAtMost(diffNormal, 10);
|
||||
});
|
||||
|
||||
it('Detects different images as different', async function () {
|
||||
|
||||
const compareImg = new ImageData({
|
||||
path: rickWhiteBG
|
||||
});
|
||||
await compareImg.hash(32);
|
||||
|
||||
const distanceNormal = leven(original.hashResult, compareImg.hashResult);
|
||||
const diffNormal = (distanceNormal/original.hashResult.length)*100;
|
||||
|
||||
assert.isAtLeast(diffNormal, 50);
|
||||
});
|
||||
|
||||
});
|
||||
75
tests/opencv.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||