Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
465c3c9acf | ||
|
|
6cee8691f5 | ||
|
|
cfb228de73 | ||
|
|
82a1a393de | ||
|
|
2fd1ffed19 | ||
|
|
7b00e1c54b | ||
|
|
bb2c5f076c | ||
|
|
8a9212def2 | ||
|
|
a9a5bd0066 | ||
|
|
f27b4a03e9 | ||
|
|
ce87285283 | ||
|
|
220c6cdd8b | ||
|
|
17440025b9 | ||
|
|
2655ae6041 | ||
|
|
a5d7b473a0 | ||
|
|
67a04c6cc6 | ||
|
|
c687ddbe57 | ||
|
|
980ff7da02 | ||
|
|
0f84a7cf6b | ||
|
|
51a93439bb | ||
|
|
18f115987b | ||
|
|
34faf56d5d | ||
|
|
d09a2df1e0 | ||
|
|
5349171913 | ||
|
|
e283d81fdf | ||
|
|
a606d6558c | ||
|
|
cc058388d0 | ||
|
|
4bbd170c1d | ||
|
|
c817716aa1 | ||
|
|
33f9b4a091 | ||
|
|
8d8e4405e0 | ||
|
|
ee302ee430 | ||
|
|
acbac54903 | ||
|
|
3858070cee | ||
|
|
ac5ace1f61 | ||
|
|
3d79a9217a | ||
|
|
4b6261517c | ||
|
|
d1960c68bb | ||
|
|
a8cc40e95d | ||
|
|
5c76f9ab1c | ||
|
|
a5d3c809aa | ||
|
|
3b905e6961 | ||
|
|
707547effc | ||
|
|
6b02350d96 | ||
|
|
7ff8094156 | ||
|
|
82c673c8a6 | ||
|
|
7f742d3a30 | ||
|
|
2442fc2483 | ||
|
|
e762cc29ef | ||
|
|
88db6767eb |
2
.github/workflows/dockerhub.yml
vendored
@@ -36,8 +36,6 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ endsWith(github.ref, 'master') }}
|
||||
type=ref,event=branch,enable=${{ !endsWith(github.ref, 'master') }}
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
flavor: |
|
||||
latest=false
|
||||
|
||||
|
||||
35
README.md
@@ -15,7 +15,10 @@ An example of the above that Context Bot can do now:
|
||||
|
||||
Some feature highlights:
|
||||
* Simple rule-action behavior can be combined to create any level of complexity in behavior
|
||||
* One instance can manage all moderated subreddits for the authenticated account
|
||||
* Server/client architecture
|
||||
* Default/no configuration runs "All In One" behavior
|
||||
* Additional configuration allows web interface to connect to multiple servers
|
||||
* Each server instance can run multiple reddit accounts as bots
|
||||
* **Per-subreddit configuration** is handled by JSON stored in the subreddit wiki
|
||||
* Any text-based actions (comment, submission, message, usernotes, ban, etc...) can be configured via a wiki page or raw text in JSON and support [mustache](https://mustache.github.io) [templating](/docs/actionTemplating.md)
|
||||
* History-based rules support multiple "valid window" types -- [ISO 8601 Durations](https://en.wikipedia.org/wiki/ISO_8601#Durations), [Day.js Durations](https://day.js.org/docs/en/durations/creating), and submission/comment count limits.
|
||||
@@ -27,7 +30,7 @@ Some feature highlights:
|
||||
* Support for [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) as criteria or Actions (writing notes)
|
||||
* Docker container support
|
||||
* Event notification via Discord
|
||||
* **Web interface** for monitoring and administration
|
||||
* **Web interface** for monitoring, administration, and oauth bot authentication
|
||||
|
||||
# Table of Contents
|
||||
|
||||
@@ -89,9 +92,12 @@ Context Bot's configuration can be written in JSON, [JSON5](https://json5.org/)
|
||||
|
||||
## Web UI and Screenshots
|
||||
|
||||
RCB comes equipped with a web interface designed for use by both moderators and bot operators. Some feature highlights:
|
||||
### Dashboard
|
||||
|
||||
CM comes equipped with a dashboard designed for use by both moderators and bot operators.
|
||||
|
||||
* Authentication via Reddit OAuth -- only accessible if you are the bot operator or a moderator of a subreddit the bot moderates
|
||||
* Connect to multiple ContextMod instances (specified in configuration)
|
||||
* Monitor API usage/rates
|
||||
* Monitoring and administration **per subreddit:**
|
||||
* Start/stop/pause various bot components
|
||||
@@ -102,10 +108,31 @@ RCB comes equipped with a web interface designed for use by both moderators and
|
||||
|
||||

|
||||
|
||||
Additionally, a helper webpage is available to help initial setup of your bot with reddit's oauth authentication. [Learn more about using the oauth helper.](docs/botAuthentication.md#cm-oauth-helper-recommended)
|
||||
### Bot Setup/Authentication
|
||||
|
||||
A bot oauth helper allows operators to define oauth credentials/permissions and then generate unique, one-time invite links that allow moderators to authenticate their own bots without operator assistance. [Learn more about using the oauth helper.](docs/botAuthentication.md#cm-oauth-helper-recommended)
|
||||
|
||||
Operator view/invite link generation:
|
||||
|
||||

|
||||
|
||||
Moderator view/invite and authorization:
|
||||
|
||||

|
||||
|
||||
### Configuration Editor
|
||||
|
||||
A built-in editor using [monaco-editor](https://microsoft.github.io/monaco-editor/) makes editing configurations easy:
|
||||
|
||||
* Automatic JSON syntax validation and formatting
|
||||
* Automatic Schema (subreddit or operator) validation
|
||||
* All properties are annotated via hover popups
|
||||
* Unauthenticated view via `yourdomain.com/config`
|
||||
* Authenticated view loads subreddit configurations by simple link found on the subreddit dashboard
|
||||
* Switch schemas to edit either subreddit or operator configurations
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
[MIT](/LICENSE)
|
||||
|
||||
@@ -12,6 +12,7 @@ At the end of this process you will have this info:
|
||||
* clientSecret
|
||||
* refreshToken
|
||||
* accessToken
|
||||
* redirectUri
|
||||
|
||||
**Note:** If you already have this information you can skip this guide **but make sure your redirect uri is correct if you plan on using the web interface.**
|
||||
|
||||
@@ -28,11 +29,11 @@ At the end of this process you will have this info:
|
||||
Visit [your reddit preferences](https://www.reddit.com/prefs/apps) and at the bottom of the page go through the **create an(other) app** process.
|
||||
* Give it a **name**
|
||||
* Choose **web app**
|
||||
* If you know what you will use for **redirect uri** go ahead and use it, otherwise use **http://localhost:8085** for now
|
||||
* If you know what you will use for **redirect uri** go ahead and use it, otherwise use **http://localhost:8085/callback**
|
||||
|
||||
Click **create app**.
|
||||
|
||||
Then write down your **Client ID, Client Secret, and redirect uri** somewhere (or keep this webpage open)
|
||||
Then write down your **Client ID, Client Secret, and Redirect Uri** somewhere (or keep this webpage open)
|
||||
|
||||
# Authenticate Your Bot Account
|
||||
|
||||
@@ -42,18 +43,31 @@ There are **two ways** you can authenticate your bot account. It is recommended
|
||||
|
||||
This method will use CM's built in oauth flow. It is recommended because it will ensure your bot is authenticated with the correct oauth permissions.
|
||||
|
||||
### Start CM with Client ID/Secret
|
||||
### Start CM with Client ID/Secret and Operator
|
||||
|
||||
Start the application while providing the **Client ID** and **Client Secret** you received. Refer to the [operator config guide](/docs/operatorConfiguration.md) if you need help with this.
|
||||
Start the application and provide these to your configuration:
|
||||
|
||||
* **Client ID**
|
||||
* **Client Secret**
|
||||
* **Redirect URI**
|
||||
* **Operator**
|
||||
|
||||
It is important you define **Operator** because the auth route is **protected.** You must login to the application in order to access the route.
|
||||
|
||||
Refer to the [operator config guide](/docs/operatorConfiguration.md) if you need help with this.
|
||||
|
||||
Examples:
|
||||
|
||||
* CLI - `node src/index.js --clientId=myId --clientSecret=mySecret`
|
||||
* Docker - `docker run -e "CLIENT_ID=myId" -e "CLIENT_SECRET=mySecret" foxxmd/context-mod`
|
||||
* CLI - `node src/index.js --clientId=myId --clientSecret=mySecret --redirectUri="http://localhost:8085/callback" --operator=FoxxMD`
|
||||
* Docker - `docker run -e "CLIENT_ID=myId" -e "CLIENT_SECRET=mySecret" -e "OPERATOR=FoxxMD" -e "REDIRECT_URI=http://localhost:8085/callback" foxxmd/context-mod`
|
||||
|
||||
Then open the CM web interface (default is [http://localhost:8085](http://localhost:8085))
|
||||
### Create An Auth Invite
|
||||
|
||||
Follow the directions in the helper to finish authenticating your bot and get your credentials (Access Token and Refresh Token)
|
||||
Then open the CM web interface (default is [http://localhost:8085](http://localhost:8085)) and login.
|
||||
|
||||
After logging in you should be automatically redirected the auth page. If you are not then visit [http://localhost:8085/auth/helper](http://localhost:8085/auth/helper))
|
||||
|
||||
Follow the directions in the helper to create an **auth invite link.** Open this link and then follow the directions to authenticate your bot. At the end of the process you will receive an **Access Token** and **Refresh Token**
|
||||
|
||||
## Aardvark OAuth Helper
|
||||
|
||||
@@ -88,6 +102,7 @@ At the end of the last step you chose you should now have this information saved
|
||||
* clientSecret
|
||||
* refreshToken
|
||||
* accessToken
|
||||
* redirectUri
|
||||
|
||||
This is all the information you need to run your bot with CM.
|
||||
|
||||
|
||||
@@ -4,7 +4,9 @@ This getting started guide is for **reddit moderators** -- that is, someone who
|
||||
# Table of Contents
|
||||
|
||||
* [Prior Knowledge](#prior-knowledge)
|
||||
* [Mod the Bot](#mod-the-bot)
|
||||
* [Choose A Bot](#choose-a-bot)
|
||||
* [Use The Operator's Bot](#use-the-operators-bot)
|
||||
* [Bring Your Own Bot (BYOB)](#bring-your-own-bot-byob)
|
||||
* [Creating Configuration](#configuring-the-bot)
|
||||
* [Monitor the Bot](#monitor-the-bot)
|
||||
|
||||
@@ -15,9 +17,26 @@ Before continuing with this guide you should first make sure you understand how
|
||||
* [How It Works](/docs#how-it-works)
|
||||
* [Core Concepts](/docs#concepts)
|
||||
|
||||
# Mod The Bot
|
||||
# Choose A Bot
|
||||
|
||||
First ensure that you are in communication with the **operator** for this bot. The bot **will not automatically accept a moderator invitation,** it must be manually done by the bot operator. This is an intentional barrier to ensure moderators and the operator are familiar with their respective needs and have some form of trust.
|
||||
First determine what bot (reddit account) you want to run ContextMod with. (You may have already discussed this with your operator)
|
||||
|
||||
## Use the Operator's Bot
|
||||
|
||||
If the Operator has communicated that **you should add a bot they control as a moderator** to your subreddit this is the option you will use.
|
||||
|
||||
**Pros:**
|
||||
|
||||
* Do not have to create and keep track of another reddit account
|
||||
* Easiest option in terms of setup for both moderators and operator
|
||||
|
||||
**Cons:**
|
||||
|
||||
* Shared api quota among other moderated subreddits (not great for high-volume subreddits)
|
||||
|
||||
___
|
||||
|
||||
Ensure that you are in communication with the **operator** for this bot. The bot **will not automatically accept a moderator invitation,** it must be manually done by the bot operator. This is an intentional barrier to ensure moderators and the operator are familiar with their respective needs and have some form of trust.
|
||||
|
||||
Now invite the bot to moderate your subreddit. The bot should have at least these permissions:
|
||||
|
||||
@@ -27,6 +46,30 @@ Now invite the bot to moderate your subreddit. The bot should have at least thes
|
||||
|
||||
Additionally, the bot must have the **Manage Wiki Pages** permission if you plan to use [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes). If you are not planning on using this feature and do not want the bot to have this permission then you **must** ensure the bot has visibility to the configuration wiki page (detailed below).
|
||||
|
||||
## Bring Your Own Bot (BYOB)
|
||||
|
||||
If the operator has communicated that **they want to use a bot you control** this is the option you will use.
|
||||
|
||||
**Pros:**
|
||||
|
||||
* **Dedicated API quota**
|
||||
* This is basically a requirement if your subreddit has high-volume activity and you plan on running checks on comments
|
||||
* More security guarantees since you control the account
|
||||
* **Note:** authenticating an account does NOT give the operator access to view or change the email/password for the account
|
||||
* Established history in your subreddit
|
||||
|
||||
**Cons:**
|
||||
|
||||
* More setup required for both moderators and operators
|
||||
|
||||
___
|
||||
|
||||
The **operator** will send you an **invite link** that you will use to authenticate your bot with the operator's application. Example link: `https://operatorsUrl.com/auth/invite?invite=4kf9n3o03ncd4nd`
|
||||
|
||||
Review the information shown on the invite link webpage and then follow the directions shown to authorize your bot for the operator.
|
||||
|
||||
**Note:** There is information display **after** authentication that you will need to communicate to your operator -- **Refresh** and **Access** token values. Make sure you save these somewhere as the invite link is **one-use only.**
|
||||
|
||||
# Configuring the Bot
|
||||
|
||||
## Setup wiki page
|
||||
|
||||
@@ -52,7 +52,7 @@ tsc -p .
|
||||
|
||||
# Bot Authentication
|
||||
|
||||
Next you need to create your bot and authenticate it with Reddit. Follow the [bot authentication guide](/docs/botAuthentication.md) to complete this step.
|
||||
Next you need to create a bot and authenticate it with Reddit. Follow the [bot authentication guide](/docs/botAuthentication.md) to complete this step.
|
||||
|
||||
# Instance Configuration
|
||||
|
||||
|
||||
@@ -14,17 +14,15 @@ activities the Bot runs on.
|
||||
|
||||
# Minimum Required Configuration
|
||||
|
||||
The minimum required configuration variables to run the bot on subreddits are:
|
||||
| property | Server And Web | Server Only | Web/Bot-Auth Only |
|
||||
|:--------------:|:------------------:|:------------------:|:------------------:|
|
||||
| `clientId` | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| `clientSecret` | :heavy_check_mark: | :heavy_check_mark: | :heavy_check_mark: |
|
||||
| `redirectUri` | :heavy_check_mark: | :x: | :heavy_check_mark: |
|
||||
| `refreshToken` | :heavy_check_mark: | :heavy_check_mark: | :x: |
|
||||
| `accessToken` | :heavy_check_mark: | :heavy_check_mark: | :x: |
|
||||
|
||||
* clientId
|
||||
* clientSecret
|
||||
* refreshToken
|
||||
* accessToken
|
||||
|
||||
However, only **clientId** and **clientSecret** are required to run the **oauth helper** mode in order to generate the last two
|
||||
configuration variables.
|
||||
|
||||
Refer to the **[Bot Authentication guide](/docs/botAuthentication.md)** to retrieve the above credentials.
|
||||
Refer to the **[Bot Authentication guide](/docs/botAuthentication.md)** to retrieve credentials.
|
||||
|
||||
# Defining Configuration
|
||||
|
||||
@@ -48,6 +46,17 @@ noted with the same symbol as above. The value shown is the default.
|
||||
|
||||
[**See the Operator Config Schema here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FOperatorConfig.json)
|
||||
|
||||
## Defining Multiple Bots or CM Instances
|
||||
|
||||
One ContextMod instance can
|
||||
|
||||
* Run multiple bots (multiple reddit accounts -- each as a bot)
|
||||
* Connect to many other, independent, ContextMod instances
|
||||
|
||||
However, the default configuration (using **ENV/ARG**) assumes your intention is to run one bot (one reddit account) on one CM instance without these additional features. This is to make this mode of operation easier for users with this intention.
|
||||
|
||||
To take advantage of this additional features you **must** use a **FILE** configuration. Learn about how this works and how to configure this scenario in the [Architecture Documentation.](/docs/serverClientArchitecture.md)
|
||||
|
||||
## CLI Usage
|
||||
|
||||
Running CM from the command line is accomplished with the following command:
|
||||
@@ -112,14 +121,18 @@ Below are examples of the minimum required config to run the application using a
|
||||
Using **FILE**
|
||||
<details>
|
||||
|
||||
```json
|
||||
```json5
|
||||
{
|
||||
"credentials": {
|
||||
"clientId": "f4b4df1c7b2",
|
||||
"clientSecret": "34v5q1c56ub",
|
||||
"refreshToken": "34_f1w1v4",
|
||||
"accessToken": "p75_1c467b2"
|
||||
}
|
||||
"bots": [
|
||||
{
|
||||
"credentials": {
|
||||
"clientId": "f4b4df1c7b2",
|
||||
"clientSecret": "34v5q1c56ub",
|
||||
"refreshToken": "34_f1w1v4",
|
||||
"accessToken": "p75_1c467b2"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -157,10 +170,8 @@ An example of using multiple configuration levels together IE all are provided t
|
||||
|
||||
```json
|
||||
{
|
||||
"credentials": {
|
||||
"clientId": "f4b4df1c7b2",
|
||||
"refreshToken": "34_f1w1v4",
|
||||
"accessToken": "p75_1c467b2"
|
||||
"logging": {
|
||||
"level": "debug"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -173,9 +184,10 @@ An example of using multiple configuration levels together IE all are provided t
|
||||
|
||||
```
|
||||
CLIENT_SECRET=34v5q1c56ub
|
||||
REFRESH_TOKEN=34_f1w1v4
|
||||
ACCESS_TOKEN=p75_1c467b2
|
||||
SUBREDDITS=sub1,sub2,sub3
|
||||
PORT=9008
|
||||
LOG_LEVEL=DEBUG
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -185,7 +197,7 @@ LOG_LEVEL=DEBUG
|
||||
<details>
|
||||
|
||||
```
|
||||
node src/index.js run --subreddits=sub1
|
||||
node src/index.js run --subreddits=sub1 --clientId=34v5q1c56ub
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -202,6 +214,52 @@ port: 9008
|
||||
log level: debug
|
||||
```
|
||||
|
||||
## Configuring Client for Many Instances
|
||||
|
||||
See the [Architecture Docs](/docs/serverClientArchitecture.md) for more information.
|
||||
|
||||
<details>
|
||||
|
||||
```json5
|
||||
{
|
||||
"bots": [
|
||||
{
|
||||
"credentials": {
|
||||
"clientId": "f4b4df1c7b2",
|
||||
"clientSecret": "34v5q1c56ub",
|
||||
"refreshToken": "34_f1w1v4",
|
||||
"accessToken": "p75_1c467b2"
|
||||
}
|
||||
}
|
||||
],
|
||||
"web": {
|
||||
"credentials": {
|
||||
"clientId": "f4b4df1c7b2",
|
||||
"clientSecret": "34v5q1c56ub",
|
||||
"redirectUri": "http://localhost:8085/callback"
|
||||
},
|
||||
"clients": [
|
||||
// server application running on this same CM instance
|
||||
{
|
||||
"host": "localhost:8095",
|
||||
"secret": "localSecret"
|
||||
},
|
||||
// a server application running somewhere else
|
||||
{
|
||||
// api endpoint and port
|
||||
"host": "mySecondContextMod.com:8095",
|
||||
"secret": "anotherSecret"
|
||||
}
|
||||
]
|
||||
},
|
||||
"api": {
|
||||
"secret": "localSecret",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
# Cache Configuration
|
||||
|
||||
CM implements two caching backend **providers**. By default all providers use `memory`:
|
||||
|
||||
BIN
docs/screenshots/editor.jpg
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
docs/screenshots/oauth-invite.jpg
Normal file
|
After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 226 KiB |
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 479 KiB |
71
docs/serverClientArchitecture.md
Normal file
@@ -0,0 +1,71 @@
|
||||
|
||||
# Overview
|
||||
|
||||
ContextMod's high-level functionality is separated into two **independently run** applications.
|
||||
|
||||
Each application consists of an [Express](https://expressjs.com/) web server that executes the core logic for that application and communicates via HTTP API calls:
|
||||
|
||||
Applications:
|
||||
|
||||
* **Server** -- Responsible for **running the bots** 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 moderators.
|
||||
|
||||
Both applications operate independently and can be run individually. The determination for which is run is made by environmental variables, operator config, or cli arguments.
|
||||
|
||||
# Authentication
|
||||
|
||||
Communication between the applications is secured using [Json Web Tokens](https://github.com/mikenicholson/passport-jwt) signed/encoded by a **shared secret** (HMAC algorithm). The secret is defined in the operator configuration.
|
||||
|
||||
# Configuration
|
||||
|
||||
## Default Mode
|
||||
|
||||
**ContextMod is designed to operate in a "monolith" mode by default.**
|
||||
|
||||
This is done by assuming that when configuration is provided by **environmental variables or CLI arguments** the user's intention is to run the client/server together with only one bot, as if ContextMod is a monolith application. When using these configuration types the same values are parsed to both the server/client to ensure interoperability/transparent usage for the operator. Some examples of this in the **operator configuration**:
|
||||
|
||||
* The **shared secret** for both client/secret cannot be defined using env/cli -- at runtime a random string is generated that is set for the value `secret` on both the `api` and `web` properties.
|
||||
* The `bots` array cannot be defined using env/cli -- a single entry is generated by the configuration parser using the combined values provided from env/cli
|
||||
* The `PORT` env/cli argument only applies to the `client` wev server to guarantee the default port for the `server` web server is used (so the `client` can connect to `server`)
|
||||
|
||||
**The end result of this default behavior is that an operator who does not care about running multiple CM instances does not need to know or understand anything about the client/server architecture.**
|
||||
|
||||
## Server
|
||||
|
||||
To run a ContextMod instance as **sever only (headless):**
|
||||
|
||||
* Config file -- define top-level `"mode":"server"`
|
||||
* ENV -- `MODE=server`
|
||||
* CLI - `node src/index.js run server`
|
||||
|
||||
The relevant sections of the **operator configuration** for the **Server** are:
|
||||
|
||||
* [`operator.name`](https://json-schema.app/view/%23/%23%2Fproperties%2Foperator?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FOperatorConfig.json) -- Define the reddit users who will be able to have full access to this server regardless of moderator status
|
||||
* `api`
|
||||
|
||||
### [`api`](https://json-schema.app/view/%23/%23%2Fproperties%2Fapi?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FOperatorConfig.json)
|
||||
|
||||
* `port` - The port the Server will listen on for incoming api requests. Cannot be the same as the Client (when running on the same host)
|
||||
* `secret` - The **shared secret** that will be used to verify incoming api requests coming from an authenticated Client.
|
||||
* `friendly` - An optional string to identify this **Server** on the client. It is recommended to provide this otherwise it will default to `host:port`
|
||||
|
||||
## Client
|
||||
|
||||
To run a ContextMod instance as **client only:**
|
||||
|
||||
* Config file -- define top-level `"mode":"client"`
|
||||
* ENV -- `MODE=client`
|
||||
* CLI - `node src/index.js run client`
|
||||
|
||||
### [`web`](https://json-schema.app/view/%23/%23%2Fproperties%2Fweb?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FOperatorConfig.json)
|
||||
|
||||
In the **operator configuration** the top-level `web` property defines the configuration for the **Client** application.
|
||||
|
||||
* `web.credentials` -- Defines the reddit oauth credentials used to authenticate users for the web interface
|
||||
* Must contain a `redirectUri` property to work
|
||||
* Credentials are parsed from ENV/CLI credentials when not specified (IE will be same as default bot)
|
||||
* `web.operators` -- Parsed from `operator.name` if not specified IE will use same users as defined for the bot operators
|
||||
* `port` -- the port the web interface will be served from, defaults to `8085`
|
||||
* `clients` -- An array of `BotConnection` objects that specify what **Server** instances the web interface should connect to. Each object should have:
|
||||
* `host` -- The URL specifying where the server api is listening ie `localhost:8085`
|
||||
* `secret` -- The **shared secret** used to sign api calls. **This should be the same as `api.secret` on the server being connected to.**
|
||||
1035
package-lock.json
generated
20
package.json
@@ -28,12 +28,15 @@
|
||||
"@awaitjs/express": "^0.8.0",
|
||||
"ajv": "^7.2.4",
|
||||
"async": "^3.2.0",
|
||||
"autolinker": "^3.14.3",
|
||||
"body-parser": "^1.19.0",
|
||||
"cache-manager": "^3.4.4",
|
||||
"cache-manager-redis-store": "^2.0.0",
|
||||
"commander": "^8.0.0",
|
||||
"cookie-parser": "^1.3.5",
|
||||
"dayjs": "^1.10.5",
|
||||
"deepmerge": "^4.2.2",
|
||||
"delimiter-stream": "^3.0.1",
|
||||
"ejs": "^3.1.6",
|
||||
"env-cmd": "^10.1.0",
|
||||
"es6-error": "^4.1.1",
|
||||
@@ -43,25 +46,36 @@
|
||||
"express-socket.io-session": "^1.3.5",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^6.4.6",
|
||||
"got": "^11.8.2",
|
||||
"he": "^1.2.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
"json5": "^2.2.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lru-cache": "^6.0.0",
|
||||
"monaco-editor": "^0.27.0",
|
||||
"mustache": "^4.2.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
"normalize-url": "^6.1.0",
|
||||
"object-hash": "^2.2.0",
|
||||
"p-event": "^4.2.0",
|
||||
"passport": "^0.4.1",
|
||||
"passport-custom": "^1.1.1",
|
||||
"passport-jwt": "^4.0.0",
|
||||
"pretty-print-json": "^1.0.3",
|
||||
"safe-stable-stringify": "^1.1.1",
|
||||
"snoostorm": "^1.5.2",
|
||||
"snoowrap": "^1.23.0",
|
||||
"socket.io": "^4.1.3",
|
||||
"tcp-port-used": "^1.0.2",
|
||||
"triple-beam": "^1.3.0",
|
||||
"typescript": "^4.3.4",
|
||||
"webhook-discord": "^3.7.7",
|
||||
"winston": "FoxxMD/winston#fbab8de969ecee578981c77846156c7f43b5f01e",
|
||||
"winston-daily-rotate-file": "^4.5.5",
|
||||
"winston-duplex": "^0.1.1",
|
||||
"winston-transport": "^4.4.0",
|
||||
"zlib": "^1.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -69,11 +83,14 @@
|
||||
"@types/async": "^3.2.7",
|
||||
"@types/cache-manager": "^3.4.2",
|
||||
"@types/cache-manager-redis-store": "^2.0.0",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/express-socket.io-session": "^1.3.6",
|
||||
"@types/he": "^1.1.1",
|
||||
"@types/http-proxy": "^1.17.7",
|
||||
"@types/js-yaml": "^4.0.1",
|
||||
"@types/jsonwebtoken": "^8.5.4",
|
||||
"@types/lodash": "^4.14.171",
|
||||
"@types/lru-cache": "^5.1.1",
|
||||
"@types/memory-cache": "^0.2.1",
|
||||
@@ -81,7 +98,10 @@
|
||||
"@types/node": "^15.6.1",
|
||||
"@types/node-fetch": "^2.5.10",
|
||||
"@types/object-hash": "^2.1.0",
|
||||
"@types/passport": "^1.0.7",
|
||||
"@types/passport-jwt": "^3.0.6",
|
||||
"@types/tcp-port-used": "^1.0.0",
|
||||
"@types/triple-beam": "^1.3.2",
|
||||
"ts-auto-guard": "*",
|
||||
"ts-json-schema-generator": "^0.93.0",
|
||||
"typescript-json-schema": "^0.50.1"
|
||||
|
||||
@@ -9,28 +9,30 @@ import {UserNoteAction, UserNoteActionJson} from "./UserNoteAction";
|
||||
import ApproveAction, {ApproveActionConfig} from "./ApproveAction";
|
||||
import BanAction, {BanActionJson} from "./BanAction";
|
||||
import {MessageAction, MessageActionJson} from "./MessageAction";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import Snoowrap from "snoowrap";
|
||||
|
||||
export function actionFactory
|
||||
(config: ActionJson, logger: Logger, subredditName: string): Action {
|
||||
(config: ActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: Snoowrap): Action {
|
||||
switch (config.kind) {
|
||||
case 'comment':
|
||||
return new CommentAction({...config as CommentActionJson, logger, subredditName});
|
||||
return new CommentAction({...config as CommentActionJson, logger, subredditName, resources, client});
|
||||
case 'lock':
|
||||
return new LockAction({...config, logger, subredditName});
|
||||
return new LockAction({...config, logger, subredditName, resources, client});
|
||||
case 'remove':
|
||||
return new RemoveAction({...config, logger, subredditName});
|
||||
return new RemoveAction({...config, logger, subredditName, resources, client});
|
||||
case 'report':
|
||||
return new ReportAction({...config as ReportActionJson, logger, subredditName});
|
||||
return new ReportAction({...config as ReportActionJson, logger, subredditName, resources, client});
|
||||
case 'flair':
|
||||
return new FlairAction({...config as FlairActionJson, logger, subredditName});
|
||||
return new FlairAction({...config as FlairActionJson, logger, subredditName, resources, client});
|
||||
case 'approve':
|
||||
return new ApproveAction({...config as ApproveActionConfig, logger, subredditName});
|
||||
return new ApproveAction({...config as ApproveActionConfig, logger, subredditName, resources, client});
|
||||
case 'usernote':
|
||||
return new UserNoteAction({...config as UserNoteActionJson, logger, subredditName});
|
||||
return new UserNoteAction({...config as UserNoteActionJson, logger, subredditName, resources, client});
|
||||
case 'ban':
|
||||
return new BanAction({...config as BanActionJson, logger, subredditName});
|
||||
return new BanAction({...config as BanActionJson, logger, subredditName, resources, client});
|
||||
case 'message':
|
||||
return new MessageAction({...config as MessageActionJson, logger, subredditName});
|
||||
return new MessageAction({...config as MessageActionJson, logger, subredditName, resources, client});
|
||||
default:
|
||||
throw new Error('rule "kind" was not recognized.');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Action, {ActionJson, ActionOptions} from "./index";
|
||||
import {Comment, ComposeMessageParams} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {renderContent, singleton} from "../Utils/SnoowrapUtils";
|
||||
import {renderContent} from "../Utils/SnoowrapUtils";
|
||||
import {Footer, RequiredRichContent, RichContent} from "../Common/interfaces";
|
||||
import {RuleResult} from "../Rule";
|
||||
import {boolToString} from "../util";
|
||||
@@ -45,8 +45,6 @@ export class MessageAction extends Action {
|
||||
// @ts-ignore
|
||||
const author = await item.author.fetch() as RedditUser;
|
||||
|
||||
const client = singleton.getClient();
|
||||
|
||||
const msgOpts: ComposeMessageParams = {
|
||||
to: author,
|
||||
text: renderedContent,
|
||||
@@ -64,7 +62,7 @@ export class MessageAction extends Action {
|
||||
this.logger.verbose(`Message Preview => \r\n ${msgPreview}`);
|
||||
|
||||
if (!dryRun) {
|
||||
await client.composeMessage(msgOpts);
|
||||
await this.client.composeMessage(msgOpts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import {Comment, Submission} from "snoowrap";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {Logger} from "winston";
|
||||
import {RuleResult} from "../Rule";
|
||||
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {ChecksActivityState, TypedActivityStates} from "../Common/interfaces";
|
||||
import Author, {AuthorOptions} from "../Author/Author";
|
||||
import {mergeArr} from "../util";
|
||||
|
||||
export abstract class Action {
|
||||
name?: string;
|
||||
logger: Logger;
|
||||
resources: SubredditResources;
|
||||
client: Snoowrap
|
||||
authorIs: AuthorOptions;
|
||||
itemIs: TypedActivityStates;
|
||||
dryRun: boolean;
|
||||
@@ -18,6 +20,8 @@ export abstract class Action {
|
||||
const {
|
||||
enable = true,
|
||||
name = this.getKind(),
|
||||
resources,
|
||||
client,
|
||||
logger,
|
||||
subredditName,
|
||||
dryRun = false,
|
||||
@@ -31,8 +35,9 @@ export abstract class Action {
|
||||
this.name = name;
|
||||
this.dryRun = dryRun;
|
||||
this.enabled = enable;
|
||||
this.resources = ResourceManager.get(subredditName) as SubredditResources;
|
||||
this.logger = logger.child({labels: [`Action ${this.getActionUniqueName()}`]});
|
||||
this.resources = resources;
|
||||
this.client = client;
|
||||
this.logger = logger.child({labels: [`Action ${this.getActionUniqueName()}`]}, mergeArr);
|
||||
|
||||
this.authorIs = {
|
||||
exclude: exclude.map(x => new Author(x)),
|
||||
@@ -94,6 +99,8 @@ export abstract class Action {
|
||||
export interface ActionOptions extends ActionConfig {
|
||||
logger: Logger;
|
||||
subredditName: string;
|
||||
resources: SubredditResources
|
||||
client: Snoowrap
|
||||
}
|
||||
|
||||
export interface ActionConfig extends ChecksActivityState {
|
||||
|
||||
500
src/App.ts
@@ -1,490 +1,92 @@
|
||||
import Snoowrap, {Subreddit} from "snoowrap";
|
||||
import {Manager} from "./Subreddit/Manager";
|
||||
import winston, {Logger} from "winston";
|
||||
import {
|
||||
argParseInt,
|
||||
createRetryHandler, formatNumber,
|
||||
labelledFormat, logLevels,
|
||||
parseBool, parseDuration,
|
||||
parseFromJsonOrYamlToObject,
|
||||
parseSubredditName,
|
||||
sleep
|
||||
} from "./util";
|
||||
import pEvent from "p-event";
|
||||
import EventEmitter from "events";
|
||||
import CacheManager from './Subreddit/SubredditResources';
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import LoggedError from "./Utils/LoggedError";
|
||||
import {ProxiedSnoowrap, RequestTrackingSnoowrap} from "./Utils/SnoowrapClients";
|
||||
import {ModQueueStream, UnmoderatedStream} from "./Subreddit/Streams";
|
||||
import {getLogger} from "./Utils/loggerFactory";
|
||||
import {DurationString, OperatorConfig, PAUSED, RUNNING, STOPPED, SYSTEM, USER} from "./Common/interfaces";
|
||||
import { Duration } from "dayjs/plugin/duration";
|
||||
import {singleton} from "./Utils/SnoowrapUtils";
|
||||
|
||||
const {transports} = winston;
|
||||
|
||||
const snooLogWrapper = (logger: Logger) => {
|
||||
return {
|
||||
warn: (...args: any[]) => logger.warn(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
debug: (...args: any[]) => logger.debug(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
info: (...args: any[]) => logger.info(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
trace: (...args: any[]) => logger.debug(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
}
|
||||
}
|
||||
import {Invokee, OperatorConfig} from "./Common/interfaces";
|
||||
import Bot from "./Bot";
|
||||
import {castArray} from "lodash";
|
||||
import LoggedError from "./Utils/LoggedError";
|
||||
|
||||
export class App {
|
||||
|
||||
client: Snoowrap;
|
||||
subreddits: string[];
|
||||
subManagers: Manager[] = [];
|
||||
bots: Bot[]
|
||||
logger: Logger;
|
||||
wikiLocation: string;
|
||||
dryRun?: true | undefined;
|
||||
heartbeatInterval: number;
|
||||
nextHeartbeat?: Dayjs;
|
||||
heartBeating: boolean = false;
|
||||
//apiLimitWarning: number;
|
||||
softLimit: number | string = 250;
|
||||
hardLimit: number | string = 50;
|
||||
nannyMode?: 'soft' | 'hard';
|
||||
nextExpiration!: Dayjs;
|
||||
botName!: string;
|
||||
botLink!: string;
|
||||
maxWorkers: number;
|
||||
startedAt: Dayjs = dayjs();
|
||||
sharedModqueue: boolean = false;
|
||||
|
||||
apiSample: number[] = [];
|
||||
interval: any;
|
||||
apiRollingAvg: number = 0;
|
||||
apiEstDepletion?: Duration;
|
||||
depletedInSecs: number = 0;
|
||||
error: any;
|
||||
|
||||
constructor(config: OperatorConfig) {
|
||||
const {
|
||||
operator: {
|
||||
botName,
|
||||
name,
|
||||
},
|
||||
subreddits: {
|
||||
names = [],
|
||||
wikiConfig,
|
||||
dryRun,
|
||||
heartbeatInterval,
|
||||
},
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
},
|
||||
snoowrap: {
|
||||
proxy,
|
||||
debug,
|
||||
},
|
||||
polling: {
|
||||
sharedMod,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers,
|
||||
},
|
||||
caching: {
|
||||
authorTTL,
|
||||
provider: {
|
||||
store
|
||||
}
|
||||
},
|
||||
api: {
|
||||
softLimit,
|
||||
hardLimit,
|
||||
}
|
||||
notifications,
|
||||
bots = [],
|
||||
} = config;
|
||||
|
||||
CacheManager.setDefaultsFromConfig(config);
|
||||
|
||||
this.dryRun = parseBool(dryRun) === true ? true : undefined;
|
||||
this.heartbeatInterval = heartbeatInterval;
|
||||
//this.apiLimitWarning = argParseInt(apiLimitWarning);
|
||||
this.softLimit = softLimit;
|
||||
this.hardLimit = hardLimit;
|
||||
this.wikiLocation = wikiConfig;
|
||||
this.sharedModqueue = sharedMod;
|
||||
if(botName !== undefined) {
|
||||
this.botName = botName;
|
||||
}
|
||||
|
||||
this.logger = getLogger(config.logging);
|
||||
|
||||
this.logger.info(`Operators: ${name.length === 0 ? 'None Specified' : name.join(', ')}`)
|
||||
|
||||
let mw = maxWorkers;
|
||||
if(maxWorkers < 1) {
|
||||
this.logger.warn(`Max queue workers must be greater than or equal to 1 (Specified: ${maxWorkers})`);
|
||||
mw = 1;
|
||||
}
|
||||
this.maxWorkers = mw;
|
||||
this.bots = bots.map(x => new Bot(x, this.logger));
|
||||
|
||||
if (this.dryRun) {
|
||||
this.logger.info('Running in DRYRUN mode');
|
||||
}
|
||||
|
||||
this.subreddits = names.map(parseSubredditName);
|
||||
|
||||
const creds = {
|
||||
userAgent: `web:contextBot:dev`,
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
};
|
||||
|
||||
const missingCreds = [];
|
||||
for(const [k,v] of Object.entries(creds)) {
|
||||
if(v === undefined || v === '' || v === null) {
|
||||
missingCreds.push(k);
|
||||
}
|
||||
}
|
||||
if(missingCreds.length > 0) {
|
||||
this.logger.error('There are credentials missing that would prevent initializing the Reddit API Client and subsequently the rest of the application');
|
||||
this.logger.error(`Missing credentials: ${missingCreds.join(', ')}`)
|
||||
this.logger.info(`If this is a first-time setup use the 'web' command for a web-based guide to configuring your application`);
|
||||
this.logger.info(`Or check the USAGE section of the readme for the correct naming of these arguments/environment variables`);
|
||||
throw new LoggedError(`Missing credentials: ${missingCreds.join(', ')}`);
|
||||
}
|
||||
|
||||
this.client = proxy === undefined ? new Snoowrap(creds) : new ProxiedSnoowrap({...creds, proxy});
|
||||
this.client.config({
|
||||
warnings: true,
|
||||
maxRetryAttempts: 5,
|
||||
debug,
|
||||
logger: snooLogWrapper(this.logger.child({labels: ['Snoowrap']})),
|
||||
continueAfterRatelimitError: true,
|
||||
process.on('uncaughtException', (e) => {
|
||||
this.error = e;
|
||||
});
|
||||
|
||||
singleton.setClient(this.client);
|
||||
|
||||
const retryHandler = createRetryHandler({maxRequestRetry: 8, maxOtherRetry: 1}, this.logger);
|
||||
|
||||
const modStreamErrorListener = (name: string) => async (err: any) => {
|
||||
this.logger.error('Polling error occurred', err);
|
||||
const shouldRetry = await retryHandler(err);
|
||||
if(shouldRetry) {
|
||||
defaultUnmoderatedStream.startInterval();
|
||||
} else {
|
||||
for(const m of this.subManagers) {
|
||||
if(m.modStreamCallbacks.size > 0) {
|
||||
m.notificationManager.handle('runStateChanged', `${name.toUpperCase()} Polling Stopped`, 'Encountered too many errors from Reddit while polling. Will try to restart on next heartbeat.');
|
||||
}
|
||||
process.on('unhandledRejection', (e) => {
|
||||
this.error = e;
|
||||
});
|
||||
process.on('exit', async (code) => {
|
||||
if(code === 0) {
|
||||
await this.onTerminate();
|
||||
} else if(this.error !== undefined) {
|
||||
let errMsg;
|
||||
if(typeof this.error === 'object' && this.error.message !== undefined) {
|
||||
errMsg = this.error.message;
|
||||
} else if(typeof this.error === 'string') {
|
||||
errMsg = this.error;
|
||||
}
|
||||
this.logger.error(`Mod stream ${name.toUpperCase()} encountered too many errors while polling. Will try to restart on next heartbeat.`);
|
||||
await this.onTerminate(`Application exited due to an unexpected error${errMsg !== undefined ? `: ${errMsg}` : ''}`);
|
||||
} else {
|
||||
await this.onTerminate(`Application exited with unclean exit signal (${code})`);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultUnmoderatedStream = new UnmoderatedStream(this.client, {subreddit: 'mod'});
|
||||
// @ts-ignore
|
||||
defaultUnmoderatedStream.on('error', modStreamErrorListener('unmoderated'));
|
||||
const defaultModqueueStream = new ModQueueStream(this.client, {subreddit: 'mod'});
|
||||
// @ts-ignore
|
||||
defaultModqueueStream.on('error', modStreamErrorListener('modqueue'));
|
||||
CacheManager.modStreams.set('unmoderated', defaultUnmoderatedStream);
|
||||
CacheManager.modStreams.set('modqueue', defaultModqueueStream);
|
||||
});
|
||||
}
|
||||
|
||||
async onTerminate(reason = 'The application was shutdown') {
|
||||
for(const m of this.subManagers) {
|
||||
await m.notificationManager.handle('runStateChanged', 'Application Shutdown', reason);
|
||||
for(const m of this.bots) {
|
||||
//await m.notificationManager.handle('runStateChanged', 'Application Shutdown', reason);
|
||||
}
|
||||
}
|
||||
|
||||
async testClient() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await this.client.getMe();
|
||||
this.logger.info('Test API call successful');
|
||||
} catch (err) {
|
||||
this.logger.error('An error occurred while trying to initialize the Reddit API Client which would prevent the entire application from running.');
|
||||
if(err.name === 'StatusCodeError') {
|
||||
const authHeader = err.response.headers['www-authenticate'];
|
||||
if (authHeader !== undefined && authHeader.includes('insufficient_scope')) {
|
||||
this.logger.error('Reddit responded with a 403 insufficient_scope. Please ensure you have chosen the correct scopes when authorizing your account.');
|
||||
} else if(err.statusCode === 401) {
|
||||
this.logger.error('It is likely a credential is missing or incorrect. Check clientId, clientSecret, refreshToken, and accessToken');
|
||||
}
|
||||
this.logger.error(`Error Message: ${err.message}`);
|
||||
} else {
|
||||
this.logger.error(err);
|
||||
}
|
||||
err.logged = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async buildManagers(subreddits: string[] = []) {
|
||||
let availSubs = [];
|
||||
// @ts-ignore
|
||||
const user = await this.client.getMe().fetch();
|
||||
this.botLink = `https://reddit.com/user/${user.name}`;
|
||||
this.logger.info(`Reddit API Limit Remaining: ${this.client.ratelimitRemaining}`);
|
||||
this.logger.info(`Authenticated Account: u/${user.name}`);
|
||||
|
||||
const botNameFromConfig = this.botName !== undefined;
|
||||
if(this.botName === undefined) {
|
||||
this.botName = `u/${user.name}`;
|
||||
}
|
||||
this.logger.info(`Bot Name${botNameFromConfig ? ' (from config)' : ''}: ${this.botName}`);
|
||||
|
||||
for (const sub of await this.client.getModeratedSubreddits()) {
|
||||
// TODO don't know a way to check permissions yet
|
||||
availSubs.push(sub);
|
||||
}
|
||||
this.logger.info(`${this.botName} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
|
||||
|
||||
let subsToRun: Subreddit[] = [];
|
||||
const subsToUse = subreddits.length > 0 ? subreddits.map(parseSubredditName) : this.subreddits;
|
||||
if (subsToUse.length > 0) {
|
||||
this.logger.info(`Operator-defined subreddit constraints detected (CLI argument or environmental variable), will try to run on: ${subsToUse.join(', ')}`);
|
||||
for (const sub of subsToUse) {
|
||||
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.toLowerCase())
|
||||
if (asub === undefined) {
|
||||
this.logger.warn(`Will not run on ${sub} because is not modded by, or does not have appropriate permissions to mod with, for this client.`);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const fetchedSub = await asub.fetch();
|
||||
subsToRun.push(fetchedSub);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// otherwise assume all moddable subs from client should be run on
|
||||
this.logger.info('No user-defined subreddit constraints detected, will try to run on all');
|
||||
subsToRun = availSubs;
|
||||
}
|
||||
|
||||
let subSchedule: Manager[] = [];
|
||||
// get configs for subs we want to run on and build/validate them
|
||||
for (const sub of subsToRun) {
|
||||
const manager = new Manager(sub, this.client, this.logger, {dryRun: this.dryRun, sharedModqueue: this.sharedModqueue, wikiLocation: this.wikiLocation, botName: this.botName, maxWorkers: this.maxWorkers});
|
||||
try {
|
||||
await manager.parseConfiguration('system', true, {suppressNotification: true});
|
||||
} catch (err) {
|
||||
if (!(err instanceof LoggedError)) {
|
||||
this.logger.error(`Config was not valid:`, {subreddit: sub.display_name_prefixed});
|
||||
this.logger.error(err, {subreddit: sub.display_name_prefixed});
|
||||
}
|
||||
}
|
||||
subSchedule.push(manager);
|
||||
}
|
||||
this.subManagers = subSchedule;
|
||||
}
|
||||
|
||||
async heartbeat() {
|
||||
try {
|
||||
this.heartBeating = true;
|
||||
while (true) {
|
||||
this.nextHeartbeat = dayjs().add(this.heartbeatInterval, 'second');
|
||||
await sleep(this.heartbeatInterval * 1000);
|
||||
const heartbeat = `HEARTBEAT -- API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ~${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion === undefined ? 'N/A' : this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`
|
||||
this.logger.info(heartbeat);
|
||||
for (const s of this.subManagers) {
|
||||
if(s.botState.state === STOPPED && s.botState.causedBy === USER) {
|
||||
this.logger.debug('Skipping config check/restart on heartbeat due to previously being stopped by user', {subreddit: s.displayLabel});
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const newConfig = await s.parseConfiguration();
|
||||
if(newConfig || (s.queueState.state !== RUNNING && s.queueState.causedBy === SYSTEM))
|
||||
{
|
||||
await s.startQueue('system', {reason: newConfig ? 'Config updated on heartbeat triggered reload' : 'Heartbeat detected non-running queue'});
|
||||
async initBots(causedBy: Invokee = 'system') {
|
||||
for (const b of this.bots) {
|
||||
if (b.error === undefined) {
|
||||
try {
|
||||
await b.testClient();
|
||||
await b.buildManagers();
|
||||
b.runManagers(causedBy).catch((err) => {
|
||||
this.logger.error(`Unexpected error occurred while running Bot ${b.botName}. Bot must be re-built to restart`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
if(newConfig || (s.eventsState.state !== RUNNING && s.eventsState.causedBy === SYSTEM))
|
||||
{
|
||||
await s.startEvents('system', {reason: newConfig ? 'Config updated on heartbeat triggered reload' : 'Heartbeat detected non-running events'});
|
||||
}
|
||||
if(s.botState.state !== RUNNING && s.eventsState.state === RUNNING && s.queueState.state === RUNNING) {
|
||||
s.botState = {
|
||||
state: RUNNING,
|
||||
causedBy: 'system',
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info('Stopping event polling to prevent activity processing queue from backing up. Will be restarted when config update succeeds.')
|
||||
await s.stopEvents('system', {reason: 'Invalid config will cause events to pile up in queue. Will be restarted when config update succeeds (next heartbeat).'});
|
||||
if(!(err instanceof LoggedError)) {
|
||||
this.logger.error(err, {subreddit: s.displayLabel});
|
||||
}
|
||||
if(this.nextHeartbeat !== undefined) {
|
||||
this.logger.info(`Will retry parsing config on next heartbeat (in ${dayjs.duration(this.nextHeartbeat.diff(dayjs())).humanize()})`, {subreddit: s.displayLabel});
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.runModStreams(true);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Error occurred during heartbeat', err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.nextHeartbeat = undefined;
|
||||
this.heartBeating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async runModStreams(notify = false) {
|
||||
for(const [k,v] of CacheManager.modStreams) {
|
||||
if(!v.running && v.listeners('item').length > 0) {
|
||||
v.startInterval();
|
||||
this.logger.info(`Starting default ${k.toUpperCase()} mod stream`);
|
||||
if(notify) {
|
||||
for(const m of this.subManagers) {
|
||||
if(m.modStreamCallbacks.size > 0) {
|
||||
m.notificationManager.handle('runStateChanged', `${k.toUpperCase()} Polling Started`, 'Polling was successfully restarted on heartbeat.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runManagers() {
|
||||
if(this.subManagers.every(x => !x.validConfigLoaded)) {
|
||||
this.logger.warn('All managers have invalid configs!');
|
||||
}
|
||||
for (const manager of this.subManagers) {
|
||||
if (manager.validConfigLoaded && manager.botState.state !== RUNNING) {
|
||||
await manager.start('system', {reason: 'Caused by application startup'});
|
||||
}
|
||||
}
|
||||
|
||||
await this.runModStreams();
|
||||
|
||||
if (this.heartbeatInterval !== 0 && !this.heartBeating) {
|
||||
this.heartbeat();
|
||||
}
|
||||
this.runApiNanny();
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
await pEvent(emitter, 'end');
|
||||
}
|
||||
|
||||
async runApiNanny() {
|
||||
while(true) {
|
||||
await sleep(10000);
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
const nowish = dayjs().add(10, 'second');
|
||||
if(nowish.isAfter(this.nextExpiration)) {
|
||||
// it's possible no api calls are being made because of a hard limit
|
||||
// need to make an api call to update this
|
||||
// @ts-ignore
|
||||
await this.client.getMe();
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
}
|
||||
const rollingSample = this.apiSample.slice(0, 7)
|
||||
rollingSample.unshift(this.client.ratelimitRemaining);
|
||||
this.apiSample = rollingSample;
|
||||
const diff = this.apiSample.reduceRight((acc: number[], curr, index) => {
|
||||
if(this.apiSample[index + 1] !== undefined) {
|
||||
const d = Math.abs(curr - this.apiSample[index + 1]);
|
||||
if(d === 0) {
|
||||
return [...acc, 0];
|
||||
}
|
||||
return [...acc, d/10];
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
this.apiRollingAvg = diff.reduce((acc, curr) => acc + curr,0) / diff.length; // api requests per second
|
||||
this.depletedInSecs = this.client.ratelimitRemaining / this.apiRollingAvg; // number of seconds until current remaining limit is 0
|
||||
this.apiEstDepletion = dayjs.duration({seconds: this.depletedInSecs});
|
||||
this.logger.debug(`API Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`);
|
||||
|
||||
|
||||
let hardLimitHit = false;
|
||||
if(typeof this.hardLimit === 'string') {
|
||||
const hardDur = parseDuration(this.hardLimit);
|
||||
hardLimitHit = hardDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
hardLimitHit = this.hardLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if(hardLimitHit) {
|
||||
if(this.nannyMode === 'hard') {
|
||||
continue;
|
||||
}
|
||||
this.logger.info(`Detected HARD LIMIT of ${this.hardLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${this.apiRollingAvg}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`All subreddit event polling has been paused`, {leaf: 'Api Nanny'});
|
||||
|
||||
for(const m of this.subManagers) {
|
||||
m.pauseEvents('system');
|
||||
m.notificationManager.handle('runStateChanged', 'Hard Limit Triggered', `Hard Limit of ${this.hardLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit event polling has been paused.`, 'system', 'warn');
|
||||
}
|
||||
|
||||
this.nannyMode = 'hard';
|
||||
continue;
|
||||
}
|
||||
|
||||
let softLimitHit = false;
|
||||
if(typeof this.softLimit === 'string') {
|
||||
const softDur = parseDuration(this.softLimit);
|
||||
softLimitHit = softDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
softLimitHit = this.softLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if(softLimitHit) {
|
||||
if(this.nannyMode === 'soft') {
|
||||
continue;
|
||||
}
|
||||
this.logger.info(`Detected SOFT LIMIT of ${this.softLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info('Trying to detect heavy usage subreddits...', {leaf: 'Api Nanny'});
|
||||
let threshold = 0.5;
|
||||
let offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
if(offenders.length === 0) {
|
||||
threshold = 0.25;
|
||||
// reduce threshold
|
||||
offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
}
|
||||
|
||||
if(offenders.length > 0) {
|
||||
this.logger.info(`Slowing subreddits using >- ${threshold}req/s:`, {leaf: 'Api Nanny'});
|
||||
for(const m of offenders) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
} catch (err) {
|
||||
if (b.error === undefined) {
|
||||
b.error = err.message;
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`Couldn't detect specific offenders, slowing all...`, {leaf: 'Api Nanny'});
|
||||
for(const m of this.subManagers) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
this.logger.error(`Bot ${b.botName} cannot recover from this error and must be re-built`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
this.nannyMode = 'soft';
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(this.nannyMode !== undefined) {
|
||||
this.logger.info('Turning off due to better conditions...', {leaf: 'Api Nanny'});
|
||||
for(const m of this.subManagers) {
|
||||
if(m.delayBy !== undefined) {
|
||||
m.delayBy = undefined;
|
||||
m.notificationManager.handle('runStateChanged', 'Normal Processing Resumed', 'Slow Mode has been turned off due to better API conditions', 'system');
|
||||
}
|
||||
if(m.queueState.state === PAUSED && m.queueState.causedBy === SYSTEM) {
|
||||
m.startQueue('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
if(m.eventsState.state === PAUSED && m.eventsState.causedBy === SYSTEM) {
|
||||
await m.startEvents('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
}
|
||||
this.nannyMode = undefined;
|
||||
}
|
||||
async destroy(causedBy: Invokee) {
|
||||
this.logger.info('Stopping all bots...');
|
||||
for(const b of this.bots) {
|
||||
await b.destroy(causedBy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
506
src/Bot/index.ts
Normal file
@@ -0,0 +1,506 @@
|
||||
import Snoowrap, {Subreddit} from "snoowrap";
|
||||
import {Logger} from "winston";
|
||||
import dayjs, {Dayjs} from "dayjs";
|
||||
import {Duration} from "dayjs/plugin/duration";
|
||||
import EventEmitter from "events";
|
||||
import {BotInstanceConfig, Invokee, PAUSED, RUNNING, SYSTEM} from "../Common/interfaces";
|
||||
import {
|
||||
createRetryHandler,
|
||||
formatNumber,
|
||||
mergeArr,
|
||||
parseBool,
|
||||
parseDuration,
|
||||
parseSubredditName,
|
||||
sleep,
|
||||
snooLogWrapper
|
||||
} from "../util";
|
||||
import {Manager} from "../Subreddit/Manager";
|
||||
import {ProxiedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
import {ModQueueStream, UnmoderatedStream} from "../Subreddit/Streams";
|
||||
import {BotResourcesManager} from "../Subreddit/SubredditResources";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import pEvent from "p-event";
|
||||
|
||||
|
||||
class Bot {
|
||||
|
||||
client!: Snoowrap;
|
||||
logger!: Logger;
|
||||
wikiLocation: string;
|
||||
dryRun?: true | undefined;
|
||||
running: boolean = false;
|
||||
subreddits: string[];
|
||||
excludeSubreddits: string[];
|
||||
subManagers: Manager[] = [];
|
||||
heartbeatInterval: number;
|
||||
nextHeartbeat?: Dayjs;
|
||||
heartBeating: boolean = false;
|
||||
|
||||
softLimit: number | string = 250;
|
||||
hardLimit: number | string = 50;
|
||||
nannyMode?: 'soft' | 'hard';
|
||||
nextExpiration: Dayjs = dayjs();
|
||||
botName?: string;
|
||||
botLink?: string;
|
||||
botAccount?: string;
|
||||
maxWorkers: number;
|
||||
startedAt: Dayjs = dayjs();
|
||||
sharedModqueue: boolean = false;
|
||||
|
||||
apiSample: number[] = [];
|
||||
apiRollingAvg: number = 0;
|
||||
apiEstDepletion?: Duration;
|
||||
depletedInSecs: number = 0;
|
||||
|
||||
error: any;
|
||||
emitter: EventEmitter = new EventEmitter();
|
||||
|
||||
cacheManager: BotResourcesManager;
|
||||
|
||||
getBotName = () => {
|
||||
return this.botName;
|
||||
}
|
||||
|
||||
getUserAgent = () => {
|
||||
return `web:contextMod:${this.botName}`
|
||||
}
|
||||
|
||||
constructor(config: BotInstanceConfig, logger: Logger) {
|
||||
const {
|
||||
notifications,
|
||||
name,
|
||||
subreddits: {
|
||||
names = [],
|
||||
exclude = [],
|
||||
wikiConfig,
|
||||
dryRun,
|
||||
heartbeatInterval,
|
||||
},
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
},
|
||||
snoowrap: {
|
||||
proxy,
|
||||
debug,
|
||||
},
|
||||
polling: {
|
||||
sharedMod,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers,
|
||||
},
|
||||
caching: {
|
||||
authorTTL,
|
||||
provider: {
|
||||
store
|
||||
}
|
||||
},
|
||||
nanny: {
|
||||
softLimit,
|
||||
hardLimit,
|
||||
}
|
||||
} = config;
|
||||
|
||||
this.cacheManager = new BotResourcesManager(config);
|
||||
|
||||
this.dryRun = parseBool(dryRun) === true ? true : undefined;
|
||||
this.softLimit = softLimit;
|
||||
this.hardLimit = hardLimit;
|
||||
this.wikiLocation = wikiConfig;
|
||||
this.heartbeatInterval = heartbeatInterval;
|
||||
this.sharedModqueue = sharedMod;
|
||||
if(name !== undefined) {
|
||||
this.botName = name;
|
||||
}
|
||||
|
||||
const getBotName = this.getBotName;
|
||||
const getUserName = this.getUserAgent;
|
||||
|
||||
this.logger = logger.child({
|
||||
get bot() {
|
||||
return getBotName();
|
||||
}
|
||||
}, mergeArr);
|
||||
|
||||
let mw = maxWorkers;
|
||||
if(maxWorkers < 1) {
|
||||
this.logger.warn(`Max queue workers must be greater than or equal to 1 (Specified: ${maxWorkers})`);
|
||||
mw = 1;
|
||||
}
|
||||
this.maxWorkers = mw;
|
||||
|
||||
if (this.dryRun) {
|
||||
this.logger.info('Running in DRYRUN mode');
|
||||
}
|
||||
|
||||
this.subreddits = names.map(parseSubredditName);
|
||||
this.excludeSubreddits = exclude.map(parseSubredditName);
|
||||
|
||||
let creds: any = {
|
||||
get userAgent() { return getUserName() },
|
||||
clientId,
|
||||
clientSecret,
|
||||
refreshToken,
|
||||
accessToken,
|
||||
};
|
||||
|
||||
const missingCreds = [];
|
||||
for(const [k,v] of Object.entries(creds)) {
|
||||
if(v === undefined || v === '' || v === null) {
|
||||
missingCreds.push(k);
|
||||
}
|
||||
}
|
||||
if(missingCreds.length > 0) {
|
||||
this.logger.error('There are credentials missing that would prevent initializing the Reddit API Client and subsequently the rest of the application');
|
||||
this.logger.error(`Missing credentials: ${missingCreds.join(', ')}`)
|
||||
this.logger.info(`If this is a first-time setup use the 'web' command for a web-based guide to configuring your application`);
|
||||
this.logger.info(`Or check the USAGE section of the readme for the correct naming of these arguments/environment variables`);
|
||||
this.error = `Missing credentials: ${missingCreds.join(', ')}`;
|
||||
//throw new LoggedError(`Missing credentials: ${missingCreds.join(', ')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.client = proxy === undefined ? new Snoowrap(creds) : new ProxiedSnoowrap({...creds, proxy});
|
||||
this.client.config({
|
||||
warnings: true,
|
||||
maxRetryAttempts: 5,
|
||||
debug,
|
||||
logger: snooLogWrapper(this.logger.child({labels: ['Snoowrap']}, mergeArr)),
|
||||
continueAfterRatelimitError: true,
|
||||
});
|
||||
} catch (err) {
|
||||
if(this.error === undefined) {
|
||||
this.error = err.message;
|
||||
this.logger.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
const retryHandler = createRetryHandler({maxRequestRetry: 8, maxOtherRetry: 1}, this.logger);
|
||||
|
||||
const modStreamErrorListener = (name: string) => async (err: any) => {
|
||||
this.logger.error('Polling error occurred', err);
|
||||
const shouldRetry = await retryHandler(err);
|
||||
if(shouldRetry) {
|
||||
defaultUnmoderatedStream.startInterval();
|
||||
} else {
|
||||
for(const m of this.subManagers) {
|
||||
if(m.modStreamCallbacks.size > 0) {
|
||||
m.notificationManager.handle('runStateChanged', `${name.toUpperCase()} Polling Stopped`, 'Encountered too many errors from Reddit while polling. Will try to restart on next heartbeat.');
|
||||
}
|
||||
}
|
||||
this.logger.error(`Mod stream ${name.toUpperCase()} encountered too many errors while polling. Will try to restart on next heartbeat.`);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultUnmoderatedStream = new UnmoderatedStream(this.client, {subreddit: 'mod'});
|
||||
// @ts-ignore
|
||||
defaultUnmoderatedStream.on('error', modStreamErrorListener('unmoderated'));
|
||||
const defaultModqueueStream = new ModQueueStream(this.client, {subreddit: 'mod'});
|
||||
// @ts-ignore
|
||||
defaultModqueueStream.on('error', modStreamErrorListener('modqueue'));
|
||||
this.cacheManager.modStreams.set('unmoderated', defaultUnmoderatedStream);
|
||||
this.cacheManager.modStreams.set('modqueue', defaultModqueueStream);
|
||||
|
||||
process.on('uncaughtException', (e) => {
|
||||
this.error = e;
|
||||
});
|
||||
process.on('unhandledRejection', (e) => {
|
||||
this.error = e;
|
||||
});
|
||||
process.on('exit', async (code) => {
|
||||
if(code === 0) {
|
||||
await this.onTerminate();
|
||||
} else if(this.error !== undefined) {
|
||||
let errMsg;
|
||||
if(typeof this.error === 'object' && this.error.message !== undefined) {
|
||||
errMsg = this.error.message;
|
||||
} else if(typeof this.error === 'string') {
|
||||
errMsg = this.error;
|
||||
}
|
||||
await this.onTerminate(`Application exited due to an unexpected error${errMsg !== undefined ? `: ${errMsg}` : ''}`);
|
||||
} else {
|
||||
await this.onTerminate(`Application exited with unclean exit signal (${code})`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onTerminate(reason = 'The application was shutdown') {
|
||||
for(const m of this.subManagers) {
|
||||
await m.notificationManager.handle('runStateChanged', 'Application Shutdown', reason);
|
||||
}
|
||||
}
|
||||
|
||||
async testClient() {
|
||||
try {
|
||||
// @ts-ignore
|
||||
await this.client.getMe();
|
||||
this.logger.info('Test API call successful');
|
||||
} catch (err) {
|
||||
this.logger.error('An error occurred while trying to initialize the Reddit API Client which would prevent the entire application from running.');
|
||||
if(err.name === 'StatusCodeError') {
|
||||
const authHeader = err.response.headers['www-authenticate'];
|
||||
if (authHeader !== undefined && authHeader.includes('insufficient_scope')) {
|
||||
this.logger.error('Reddit responded with a 403 insufficient_scope. Please ensure you have chosen the correct scopes when authorizing your account.');
|
||||
} else if(err.statusCode === 401) {
|
||||
this.logger.error('It is likely a credential is missing or incorrect. Check clientId, clientSecret, refreshToken, and accessToken');
|
||||
}
|
||||
this.logger.error(`Error Message: ${err.message}`);
|
||||
} else {
|
||||
this.logger.error(err);
|
||||
}
|
||||
this.error = `Error occurred while testing Reddit API client: ${err.message}`;
|
||||
err.logged = true;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async buildManagers(subreddits: string[] = []) {
|
||||
let availSubs = [];
|
||||
// @ts-ignore
|
||||
const user = await this.client.getMe().fetch();
|
||||
this.botLink = `https://reddit.com/user/${user.name}`;
|
||||
this.botAccount = `u/${user.name}`;
|
||||
this.logger.info(`Reddit API Limit Remaining: ${this.client.ratelimitRemaining}`);
|
||||
this.logger.info(`Authenticated Account: u/${user.name}`);
|
||||
|
||||
const botNameFromConfig = this.botName !== undefined;
|
||||
if(this.botName === undefined) {
|
||||
this.botName = `u/${user.name}`;
|
||||
}
|
||||
this.logger.info(`Bot Name${botNameFromConfig ? ' (from config)' : ''}: ${this.botName}`);
|
||||
|
||||
for (const sub of await this.client.getModeratedSubreddits()) {
|
||||
// TODO don't know a way to check permissions yet
|
||||
availSubs.push(sub);
|
||||
}
|
||||
this.logger.info(`u/${user.name} is a moderator of these subreddits: ${availSubs.map(x => x.display_name_prefixed).join(', ')}`);
|
||||
|
||||
let subsToRun: Subreddit[] = [];
|
||||
const subsToUse = subreddits.length > 0 ? subreddits.map(parseSubredditName) : this.subreddits;
|
||||
if (subsToUse.length > 0) {
|
||||
this.logger.info(`Operator-defined subreddit constraints detected (CLI argument or environmental variable), will try to run on: ${subsToUse.join(', ')}`);
|
||||
for (const sub of subsToUse) {
|
||||
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.toLowerCase())
|
||||
if (asub === undefined) {
|
||||
this.logger.warn(`Will not run on ${sub} because is not modded by, or does not have appropriate permissions to mod with, for this client.`);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const fetchedSub = await asub.fetch();
|
||||
subsToRun.push(fetchedSub);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if(this.excludeSubreddits.length > 0) {
|
||||
this.logger.info(`Will run on all moderated subreddits but user-defined excluded: ${this.excludeSubreddits.join(', ')}`);
|
||||
const normalExcludes = this.excludeSubreddits.map(x => x.toLowerCase());
|
||||
subsToRun = availSubs.filter(x => !normalExcludes.includes(x.display_name.toLowerCase()));
|
||||
} else {
|
||||
this.logger.info('No user-defined subreddit constraints detected, will run on all moderated subreddits');
|
||||
subsToRun = availSubs;
|
||||
}
|
||||
}
|
||||
|
||||
let subSchedule: Manager[] = [];
|
||||
// get configs for subs we want to run on and build/validate them
|
||||
for (const sub of subsToRun) {
|
||||
const manager = new Manager(sub, this.client, this.logger, this.cacheManager, {dryRun: this.dryRun, sharedModqueue: this.sharedModqueue, wikiLocation: this.wikiLocation, botName: this.botName, maxWorkers: this.maxWorkers});
|
||||
try {
|
||||
await manager.parseConfiguration('system', true, {suppressNotification: true});
|
||||
} catch (err) {
|
||||
if (!(err instanceof LoggedError)) {
|
||||
this.logger.error(`Config was not valid:`, {subreddit: sub.display_name_prefixed});
|
||||
this.logger.error(err, {subreddit: sub.display_name_prefixed});
|
||||
}
|
||||
}
|
||||
subSchedule.push(manager);
|
||||
}
|
||||
this.subManagers = subSchedule;
|
||||
}
|
||||
|
||||
async destroy(causedBy: Invokee) {
|
||||
this.logger.info('Stopping heartbeat and nanny processes, may take up to 5 seconds...');
|
||||
const processWait = Promise.all([pEvent(this.emitter, 'heartbeatStopped'), pEvent(this.emitter, 'nannyStopped')]);
|
||||
this.running = false;
|
||||
await processWait;
|
||||
for (const manager of this.subManagers) {
|
||||
await manager.stop(causedBy, {reason: 'App rebuild'});
|
||||
}
|
||||
this.logger.info('Bot is stopped.');
|
||||
}
|
||||
|
||||
async runModStreams(notify = false) {
|
||||
for(const [k,v] of this.cacheManager.modStreams) {
|
||||
if(!v.running && v.listeners('item').length > 0) {
|
||||
v.startInterval();
|
||||
this.logger.info(`Starting default ${k.toUpperCase()} mod stream`);
|
||||
if(notify) {
|
||||
for(const m of this.subManagers) {
|
||||
if(m.modStreamCallbacks.size > 0) {
|
||||
await m.notificationManager.handle('runStateChanged', `${k.toUpperCase()} Polling Started`, 'Polling was successfully restarted on heartbeat.');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async runManagers(causedBy: Invokee = 'system') {
|
||||
if(this.subManagers.every(x => !x.validConfigLoaded)) {
|
||||
this.logger.warn('All managers have invalid configs!');
|
||||
this.error = 'All managers have invalid configs';
|
||||
}
|
||||
for (const manager of this.subManagers) {
|
||||
if (manager.validConfigLoaded && manager.botState.state !== RUNNING) {
|
||||
await manager.start(causedBy, {reason: 'Caused by application startup'});
|
||||
}
|
||||
}
|
||||
|
||||
await this.runModStreams();
|
||||
|
||||
this.running = true;
|
||||
this.runApiNanny();
|
||||
}
|
||||
|
||||
async runApiNanny() {
|
||||
try {
|
||||
mainLoop:
|
||||
while (this.running) {
|
||||
for(let i = 0; i < 2; i++) {
|
||||
await sleep(5000);
|
||||
if (!this.running) {
|
||||
break mainLoop;
|
||||
}
|
||||
}
|
||||
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
const nowish = dayjs().add(10, 'second');
|
||||
if (nowish.isAfter(this.nextExpiration)) {
|
||||
// it's possible no api calls are being made because of a hard limit
|
||||
// need to make an api call to update this
|
||||
// @ts-ignore
|
||||
await this.client.getMe();
|
||||
this.nextExpiration = dayjs(this.client.ratelimitExpiration);
|
||||
}
|
||||
const rollingSample = this.apiSample.slice(0, 7)
|
||||
rollingSample.unshift(this.client.ratelimitRemaining);
|
||||
this.apiSample = rollingSample;
|
||||
const diff = this.apiSample.reduceRight((acc: number[], curr, index) => {
|
||||
if (this.apiSample[index + 1] !== undefined) {
|
||||
const d = Math.abs(curr - this.apiSample[index + 1]);
|
||||
if (d === 0) {
|
||||
return [...acc, 0];
|
||||
}
|
||||
return [...acc, d / 10];
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
this.apiRollingAvg = diff.reduce((acc, curr) => acc + curr, 0) / diff.length; // api requests per second
|
||||
this.depletedInSecs = this.client.ratelimitRemaining / this.apiRollingAvg; // number of seconds until current remaining limit is 0
|
||||
this.apiEstDepletion = dayjs.duration({seconds: this.depletedInSecs});
|
||||
this.logger.debug(`API Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`);
|
||||
|
||||
|
||||
let hardLimitHit = false;
|
||||
if (typeof this.hardLimit === 'string') {
|
||||
const hardDur = parseDuration(this.hardLimit);
|
||||
hardLimitHit = hardDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
hardLimitHit = this.hardLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if (hardLimitHit) {
|
||||
if (this.nannyMode === 'hard') {
|
||||
continue;
|
||||
}
|
||||
this.logger.info(`Detected HARD LIMIT of ${this.hardLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${this.apiRollingAvg}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`All subreddit event polling has been paused`, {leaf: 'Api Nanny'});
|
||||
|
||||
for (const m of this.subManagers) {
|
||||
m.pauseEvents('system');
|
||||
m.notificationManager.handle('runStateChanged', 'Hard Limit Triggered', `Hard Limit of ${this.hardLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit event polling has been paused.`, 'system', 'warn');
|
||||
}
|
||||
|
||||
this.nannyMode = 'hard';
|
||||
continue;
|
||||
}
|
||||
|
||||
let softLimitHit = false;
|
||||
if (typeof this.softLimit === 'string') {
|
||||
const softDur = parseDuration(this.softLimit);
|
||||
softLimitHit = softDur.asSeconds() > this.apiEstDepletion.asSeconds();
|
||||
} else {
|
||||
softLimitHit = this.softLimit > this.client.ratelimitRemaining;
|
||||
}
|
||||
|
||||
if (softLimitHit) {
|
||||
if (this.nannyMode === 'soft') {
|
||||
continue;
|
||||
}
|
||||
this.logger.info(`Detected SOFT LIMIT of ${this.softLimit} remaining`, {leaf: 'Api Nanny'});
|
||||
this.logger.info(`API Remaining: ${this.client.ratelimitRemaining} | Usage Rolling Avg: ${formatNumber(this.apiRollingAvg)}/s | Est Depletion: ${this.apiEstDepletion.humanize()} (${formatNumber(this.depletedInSecs, {toFixed: 0})} seconds)`, {leaf: 'Api Nanny'});
|
||||
this.logger.info('Trying to detect heavy usage subreddits...', {leaf: 'Api Nanny'});
|
||||
let threshold = 0.5;
|
||||
let offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
if (offenders.length === 0) {
|
||||
threshold = 0.25;
|
||||
// reduce threshold
|
||||
offenders = this.subManagers.filter(x => {
|
||||
const combinedPerSec = x.eventsRollingAvg + x.rulesUniqueRollingAvg;
|
||||
return combinedPerSec > threshold;
|
||||
});
|
||||
}
|
||||
|
||||
if (offenders.length > 0) {
|
||||
this.logger.info(`Slowing subreddits using >- ${threshold}req/s:`, {leaf: 'Api Nanny'});
|
||||
for (const m of offenders) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
}
|
||||
} else {
|
||||
this.logger.info(`Couldn't detect specific offenders, slowing all...`, {leaf: 'Api Nanny'});
|
||||
for (const m of this.subManagers) {
|
||||
m.delayBy = 1.5;
|
||||
m.logger.info(`SLOW MODE (Currently ~${formatNumber(m.eventsRollingAvg + m.rulesUniqueRollingAvg)}req/sec)`, {leaf: 'Api Nanny'});
|
||||
m.notificationManager.handle('runStateChanged', 'Soft Limit Triggered', `Soft Limit of ${this.softLimit} hit (API Remaining: ${this.client.ratelimitRemaining}). Subreddit queue processing will be slowed to 1.5 seconds per.`, 'system', 'warn');
|
||||
}
|
||||
}
|
||||
this.nannyMode = 'soft';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.nannyMode !== undefined) {
|
||||
this.logger.info('Turning off due to better conditions...', {leaf: 'Api Nanny'});
|
||||
for (const m of this.subManagers) {
|
||||
if (m.delayBy !== undefined) {
|
||||
m.delayBy = undefined;
|
||||
m.notificationManager.handle('runStateChanged', 'Normal Processing Resumed', 'Slow Mode has been turned off due to better API conditions', 'system');
|
||||
}
|
||||
if (m.queueState.state === PAUSED && m.queueState.causedBy === SYSTEM) {
|
||||
m.startQueue('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
if (m.eventsState.state === PAUSED && m.eventsState.causedBy === SYSTEM) {
|
||||
await m.startEvents('system', {reason: 'API Nanny has been turned off due to better API conditions'});
|
||||
}
|
||||
}
|
||||
this.nannyMode = undefined;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Error occurred during nanny loop', err);
|
||||
throw err;
|
||||
} finally {
|
||||
this.logger.info('Nanny stopped');
|
||||
this.emitter.emit('nannyStopped');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Bot;
|
||||
@@ -2,7 +2,7 @@ import {RuleSet, IRuleSet, RuleSetJson, RuleSetObjectJson} from "../Rule/RuleSet
|
||||
import {IRule, isRuleSetResult, Rule, RuleJSONConfig, RuleResult, RuleSetResult} from "../Rule";
|
||||
import Action, {ActionConfig, ActionJson} from "../Action";
|
||||
import {Logger} from "winston";
|
||||
import {Comment, Submission} from "snoowrap";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {actionFactory} from "../Action/ActionFactory";
|
||||
import {ruleFactory} from "../Rule/RuleFactory";
|
||||
import {
|
||||
@@ -27,7 +27,7 @@ import * as RuleSchema from '../Schema/Rule.json';
|
||||
import * as RuleSetSchema from '../Schema/RuleSet.json';
|
||||
import * as ActionSchema from '../Schema/Action.json';
|
||||
import {ActionObjectJson, RuleJson, RuleObjectJson, ActionJson as ActionTypeJson} from "../Common/types";
|
||||
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {Author, AuthorCriteria, AuthorOptions} from "../Author/Author";
|
||||
|
||||
const checkLogName = truncateStringToLength(25);
|
||||
@@ -48,12 +48,15 @@ export abstract class Check implements ICheck {
|
||||
dryRun?: boolean;
|
||||
notifyOnTrigger: boolean;
|
||||
resources: SubredditResources;
|
||||
client: Snoowrap;
|
||||
|
||||
constructor(options: CheckOptions) {
|
||||
const {
|
||||
enable = true,
|
||||
name,
|
||||
resources,
|
||||
description,
|
||||
client,
|
||||
condition = 'AND',
|
||||
rules = [],
|
||||
actions = [],
|
||||
@@ -73,7 +76,8 @@ export abstract class Check implements ICheck {
|
||||
|
||||
const ajv = createAjvFactory(this.logger);
|
||||
|
||||
this.resources = ResourceManager.get(subredditName) as SubredditResources;
|
||||
this.resources = resources;
|
||||
this.client = client;
|
||||
|
||||
this.name = name;
|
||||
this.description = description;
|
||||
@@ -94,12 +98,12 @@ export abstract class Check implements ICheck {
|
||||
let ruleErrors: any = [];
|
||||
if (valid) {
|
||||
const ruleConfig = r as RuleSetObjectJson;
|
||||
this.rules.push(new RuleSet({...ruleConfig, logger: this.logger, subredditName}));
|
||||
this.rules.push(new RuleSet({...ruleConfig, logger: this.logger, subredditName, resources: this.resources, client: this.client}));
|
||||
} else {
|
||||
setErrors = ajv.errors;
|
||||
valid = ajv.validate(RuleSchema, r);
|
||||
if (valid) {
|
||||
this.rules.push(ruleFactory(r as RuleJSONConfig, this.logger, subredditName));
|
||||
this.rules.push(ruleFactory(r as RuleJSONConfig, this.logger, subredditName, this.resources, this.client));
|
||||
} else {
|
||||
ruleErrors = ajv.errors;
|
||||
const leastErrorType = setErrors.length < ruleErrors ? 'RuleSet' : 'Rule';
|
||||
@@ -123,7 +127,7 @@ export abstract class Check implements ICheck {
|
||||
this.actions.push(actionFactory({
|
||||
...aj,
|
||||
dryRun: this.dryRun || aj.dryRun
|
||||
}, this.logger, subredditName));
|
||||
}, this.logger, subredditName, this.resources, this.client));
|
||||
// @ts-ignore
|
||||
a.logger = this.logger;
|
||||
} else {
|
||||
@@ -331,6 +335,8 @@ export interface CheckOptions extends ICheck {
|
||||
logger: Logger
|
||||
subredditName: string
|
||||
notifyOnTrigger?: boolean
|
||||
resources: SubredditResources
|
||||
client: Snoowrap
|
||||
}
|
||||
|
||||
export interface CheckJson extends ICheck {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Duration} from "dayjs/plugin/duration";
|
||||
import {Cache} from 'cache-manager';
|
||||
import {MESSAGE} from 'triple-beam';
|
||||
import Poll from "snoostorm/out/util/Poll";
|
||||
import Snoowrap from "snoowrap";
|
||||
|
||||
@@ -635,12 +636,12 @@ export interface SubmissionState extends ActivityState {
|
||||
* */
|
||||
export interface CommentState extends ActivityState {
|
||||
/**
|
||||
* Is this Comment Author also the Author of the Submission this comment is in?
|
||||
* */
|
||||
* Is this Comment Author also the Author of the Submission this comment is in?
|
||||
* */
|
||||
op?: boolean
|
||||
/**
|
||||
* A list of SubmissionState attributes to test the Submission this comment is in
|
||||
* */
|
||||
* A list of SubmissionState attributes to test the Submission this comment is in
|
||||
* */
|
||||
submissionState?: SubmissionState[]
|
||||
}
|
||||
|
||||
@@ -807,141 +808,127 @@ export interface ManagerStateChangeOption {
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for application-level settings IE for running the bot instance
|
||||
*
|
||||
* * To load a JSON configuration **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`
|
||||
* * To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`
|
||||
* Configuration required to connect to a CM Server
|
||||
* */
|
||||
export interface OperatorJsonConfig {
|
||||
export interface BotConnection {
|
||||
/**
|
||||
* Settings related to the user(s) running this ContextMod instance and information on the bot
|
||||
* The hostname and port the CM Server is listening on EX `localhost:8085`
|
||||
* */
|
||||
host: string
|
||||
/**
|
||||
* The **shared secret** used to sign API calls from the Client to the Server.
|
||||
*
|
||||
* This value should be the same as what is specified in the target CM's `api.secret` configuration
|
||||
* */
|
||||
secret: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Credentials required for the bot to interact with Reddit's API
|
||||
*
|
||||
* These credentials will provided to both the API and Web interface unless otherwise specified with the `web.credentials` property
|
||||
*
|
||||
* Refer to the [required credentials table](https://github.com/FoxxMD/context-mod/blob/master/docs/operatorConfiguration.md#minimum-required-configuration) to see what is necessary to run the bot.
|
||||
*
|
||||
* @examples [{"clientId": "f4b4df1_9oiu", "clientSecret": "34v5q1c564_yt7", "redirectUri": "http://localhost:8085/callback", "refreshToken": "34_f1w1v4", "accessToken": "p75_1c467b2"}]
|
||||
* */
|
||||
export interface RedditCredentials {
|
||||
/**
|
||||
* Client ID for your Reddit application
|
||||
*
|
||||
* * ENV => `CLIENT_ID`
|
||||
* * ARG => `--clientId <id>`
|
||||
*
|
||||
* @examples ["f4b4df1c7b2"]
|
||||
* */
|
||||
clientId?: string,
|
||||
/**
|
||||
* Client Secret for your Reddit application
|
||||
*
|
||||
* * ENV => `CLIENT_SECRET`
|
||||
* * ARG => `--clientSecret <id>`
|
||||
*
|
||||
* @examples ["34v5q1c56ub"]
|
||||
* */
|
||||
clientSecret?: string,
|
||||
|
||||
/**
|
||||
* Access token retrieved from authenticating an account with your Reddit Application
|
||||
*
|
||||
* * ENV => `ACCESS_TOKEN`
|
||||
* * ARG => `--accessToken <token>`
|
||||
*
|
||||
* @examples ["p75_1c467b2"]
|
||||
* */
|
||||
accessToken?: string,
|
||||
/**
|
||||
* Refresh token retrieved from authenticating an account with your Reddit Application
|
||||
*
|
||||
* * ENV => `REFRESH_TOKEN`
|
||||
* * ARG => `--refreshToken <token>`
|
||||
*
|
||||
* @examples ["34_f1w1v4"]
|
||||
* */
|
||||
refreshToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate credentials for the web interface can be provided when also running the api.
|
||||
*
|
||||
* All properties not specified will default to values given in ENV/ARG credential properties
|
||||
*
|
||||
* Refer to the [required credentials table](https://github.com/FoxxMD/context-mod/blob/master/docs/operatorConfiguration.md#minimum-required-configuration) to see what is necessary for the web interface.
|
||||
*
|
||||
* @examples [{"clientId": "f4b4df1_9oiu", "clientSecret": "34v5q1c564_yt7", "redirectUri": "http://localhost:8085/callback"}]
|
||||
* */
|
||||
export interface WebCredentials {
|
||||
/**
|
||||
* Client ID for your Reddit application
|
||||
*
|
||||
* @examples ["f4b4df1_9oiu"]
|
||||
* */
|
||||
clientId?: string,
|
||||
/**
|
||||
* Client Secret for your Reddit application
|
||||
*
|
||||
* @examples ["34v5q1c564_yt7"]
|
||||
* */
|
||||
clientSecret?: string,
|
||||
/**
|
||||
* Redirect URI for your Reddit application
|
||||
*
|
||||
* Used for:
|
||||
*
|
||||
* * accessing the web interface for monitoring bots
|
||||
* * authenticating an account to use for a bot instance
|
||||
*
|
||||
* * ENV => `REDIRECT_URI`
|
||||
* * ARG => `--redirectUri <uri>`
|
||||
*
|
||||
* @examples ["http://localhost:8085/callback"]
|
||||
* */
|
||||
redirectUri?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration for an **individual reddit account** ContextMod will run as a bot.
|
||||
*
|
||||
* Multiple bot configs may be specified (one per reddit account).
|
||||
*
|
||||
* **NOTE:** If `bots` is not specified in a `FILE` then a default `bot` is generated using `ENV/ARG` values IE `CLIENT_ID`, etc...but if `bots` IS specified the default is not generated.
|
||||
*
|
||||
* */
|
||||
export interface BotInstanceJsonConfig {
|
||||
credentials?: RedditCredentials
|
||||
/*
|
||||
* The name to display for the bot. If not specified will use the name of the reddit account IE `u/TheBotName`
|
||||
* */
|
||||
operator?: {
|
||||
/**
|
||||
* The name, or names, of the Reddit accounts, without prefix, that the operators of this bot uses.
|
||||
*
|
||||
* This is used for showing more information in the web interface IE show all logs/subreddits if even not a moderator.
|
||||
*
|
||||
* EX -- User is /u/FoxxMD then `"name": ["FoxxMD"]`
|
||||
*
|
||||
* * ENV => `OPERATOR` (if list, comma-delimited)
|
||||
* * ARG => `--operator <name...>`
|
||||
*
|
||||
* @examples [["FoxxMD","AnotherUser"]]
|
||||
* */
|
||||
name?: string | string[],
|
||||
/**
|
||||
* A **public** name to display to users of the web interface. Use this to help moderators using your bot identify who is the operator in case they need to contact you.
|
||||
*
|
||||
* Leave undefined for no public name to be displayed.
|
||||
*
|
||||
* * ENV => `OPERATOR_DISPLAY`
|
||||
* * ARG => `--operatorDisplay <name>`
|
||||
*
|
||||
* @examples ["Moderators of r/MySubreddit"]
|
||||
* */
|
||||
display?: string,
|
||||
/**
|
||||
* The name to use when identifying the bot. Defaults to name of the authenticated Reddit account IE `u/yourBotAccount`
|
||||
*
|
||||
* @examples ["u/yourBotAccount"]
|
||||
* */
|
||||
botName?: string,
|
||||
},
|
||||
name?: string
|
||||
/**
|
||||
* The credentials required for the bot to interact with Reddit's API
|
||||
*
|
||||
* **Note:** Only `clientId` and `clientSecret` are required for initial setup (to use the oauth helper) **but ALL are required to properly run the bot.**
|
||||
* */
|
||||
credentials?: {
|
||||
/**
|
||||
* Client ID for your Reddit application
|
||||
*
|
||||
* * ENV => `CLIENT_ID`
|
||||
* * ARG => `--clientId <id>`
|
||||
*
|
||||
* @examples ["f4b4df1c7b2"]
|
||||
* */
|
||||
clientId?: string,
|
||||
/**
|
||||
* Client Secret for your Reddit application
|
||||
*
|
||||
* * ENV => `CLIENT_SECRET`
|
||||
* * ARG => `--clientSecret <id>`
|
||||
*
|
||||
* @examples ["34v5q1c56ub"]
|
||||
* */
|
||||
clientSecret?: string,
|
||||
/**
|
||||
* Redirect URI for your Reddit application
|
||||
*
|
||||
* Only required if running ContextMod with a web interface (and after using oauth helper)
|
||||
*
|
||||
* * ENV => `REDIRECT_URI`
|
||||
* * ARG => `--redirectUri <uri>`
|
||||
*
|
||||
* @examples ["http://localhost:8085"]
|
||||
* @format uri
|
||||
* */
|
||||
redirectUri?: string,
|
||||
/**
|
||||
* Access token retrieved from authenticating an account with your Reddit Application
|
||||
*
|
||||
* * ENV => `ACCESS_TOKEN`
|
||||
* * ARG => `--accessToken <token>`
|
||||
*
|
||||
* @examples ["p75_1c467b2"]
|
||||
* */
|
||||
accessToken?: string,
|
||||
/**
|
||||
* Refresh token retrieved from authenticating an account with your Reddit Application
|
||||
*
|
||||
* * ENV => `REFRESH_TOKEN`
|
||||
* * ARG => `--refreshToken <token>`
|
||||
*
|
||||
* @examples ["34_f1w1v4"]
|
||||
* */
|
||||
refreshToken?: string
|
||||
},
|
||||
/**
|
||||
* Settings to configure 3rd party notifications for when ContextMod behavior occurs
|
||||
* Settings to configure 3rd party notifications for when behavior occurs
|
||||
* */
|
||||
notifications?: NotificationConfig
|
||||
/**
|
||||
* Settings to configure global logging defaults
|
||||
* */
|
||||
logging?: {
|
||||
/**
|
||||
* The minimum log level to output. The log level set will output logs at its level **and all levels above it:**
|
||||
*
|
||||
* * `error`
|
||||
* * `warn`
|
||||
* * `info`
|
||||
* * `verbose`
|
||||
* * `debug`
|
||||
*
|
||||
* Note: `verbose` will display *a lot* of information on the status/result of run rules/checks/actions etc. which is very useful for testing configurations. Once your bot is stable changing the level to `info` will reduce log noise.
|
||||
*
|
||||
* * ENV => `LOG_LEVEL`
|
||||
* * ARG => `--logLevel <level>`
|
||||
*
|
||||
* @default "verbose"
|
||||
* @examples ["verbose"]
|
||||
* */
|
||||
level?: LogLevel,
|
||||
/**
|
||||
* The absolute path to a directory where rotating log files should be stored.
|
||||
*
|
||||
* * If not present or `null` no log files will be created
|
||||
* * If `true` logs will be stored at `[working directory]/logs`
|
||||
*
|
||||
* * ENV => `LOG_DIR`
|
||||
* * ARG => `--logDir [dir]`
|
||||
*
|
||||
* @examples ["/var/log/contextmod"]
|
||||
* */
|
||||
path?: string,
|
||||
},
|
||||
|
||||
/**
|
||||
* Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior
|
||||
* */
|
||||
@@ -970,6 +957,7 @@ export interface OperatorJsonConfig {
|
||||
* */
|
||||
debug?: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings related to bot behavior for subreddits it is managing
|
||||
* */
|
||||
@@ -984,7 +972,16 @@ export interface OperatorJsonConfig {
|
||||
*
|
||||
* @examples [["mealtimevideos","programminghumor"]]
|
||||
* */
|
||||
names?: string[],
|
||||
names?: string[]
|
||||
|
||||
/**
|
||||
* Names of subreddits the bot should NOT run, based on what subreddits it moderates
|
||||
*
|
||||
* This setting is ignored if `names` is specified
|
||||
*
|
||||
* @examples [["mealtimevideos","programminghumor"]]
|
||||
* */
|
||||
exclude?: string[]
|
||||
/**
|
||||
* If `true` then all subreddits will run in dry run mode, overriding configurations
|
||||
*
|
||||
@@ -994,7 +991,7 @@ export interface OperatorJsonConfig {
|
||||
* @default false
|
||||
* @examples [false]
|
||||
* */
|
||||
dryRun?: boolean,
|
||||
dryRun?: boolean
|
||||
/**
|
||||
* The default relative url to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`
|
||||
*
|
||||
@@ -1004,7 +1001,7 @@ export interface OperatorJsonConfig {
|
||||
* @default "botconfig/contextbot"
|
||||
* @examples ["botconfig/contextbot"]
|
||||
* */
|
||||
wikiConfig?: string,
|
||||
wikiConfig?: string
|
||||
/**
|
||||
* Interval, in seconds, to perform application heartbeat
|
||||
*
|
||||
@@ -1021,7 +1018,8 @@ export interface OperatorJsonConfig {
|
||||
* @examples [300]
|
||||
* */
|
||||
heartbeatInterval?: number,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings related to default polling configurations for subreddits
|
||||
* */
|
||||
@@ -1051,72 +1049,9 @@ export interface OperatorJsonConfig {
|
||||
* @default 1
|
||||
* @examples [1]
|
||||
* */
|
||||
maxWorkers?: number,
|
||||
},
|
||||
/**
|
||||
* Settings for the web interface
|
||||
* */
|
||||
web?: {
|
||||
/**
|
||||
* Whether the web server interface should be started
|
||||
*
|
||||
* In most cases this does not need to be specified as the application will automatically detect if it is possible to start it --
|
||||
* use this to specify "cli only" behavior if you encounter errors with port/address or are paranoid
|
||||
*
|
||||
* * ENV => `WEB`
|
||||
* * ARG => `node src/index.js run [interface]` -- interface can be `web` or `cli`
|
||||
*
|
||||
* @default true
|
||||
* */
|
||||
enabled?: boolean,
|
||||
/**
|
||||
* The port for the web interface
|
||||
*
|
||||
* * ENV => `PORT`
|
||||
* * ARG => `--port <number>`
|
||||
*
|
||||
* @default 8085
|
||||
* @examples [8085]
|
||||
* */
|
||||
port?: number,
|
||||
/**
|
||||
* Settings to configure the behavior of user sessions -- the session is what the web interface uses to identify logged in users.
|
||||
* */
|
||||
session?: {
|
||||
/**
|
||||
* The cache provider to use.
|
||||
*
|
||||
* The default should be sufficient for almost all use cases
|
||||
*
|
||||
* @default "memory"
|
||||
* @examples ["memory"]
|
||||
* */
|
||||
provider?: 'memory' | 'redis' | CacheOptions,
|
||||
/**
|
||||
* The secret value used to encrypt session data
|
||||
*
|
||||
* If provider is persistent (redis) specifying a value here will ensure sessions are valid between application restarts
|
||||
*
|
||||
* When not present or `null` a random string is generated on application start
|
||||
*
|
||||
* @examples ["definitelyARandomString"]
|
||||
* */
|
||||
secret?: string,
|
||||
}
|
||||
/**
|
||||
* The default log level to filter to in the web interface
|
||||
*
|
||||
* If not specified or `null` will be same as global `logLevel`
|
||||
* */
|
||||
logLevel?: LogLevel,
|
||||
/**
|
||||
* Maximum number of log statements to keep in memory for each subreddit
|
||||
*
|
||||
* @default 200
|
||||
* @examples [200]
|
||||
* */
|
||||
maxLogs?: number,
|
||||
maxWorkers?: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings to configure the default caching behavior for each suberddit
|
||||
* */
|
||||
@@ -1175,7 +1110,7 @@ export interface OperatorJsonConfig {
|
||||
/**
|
||||
* Settings related to managing heavy API usage.
|
||||
* */
|
||||
api?: {
|
||||
nanny?: {
|
||||
/**
|
||||
* When `api limit remaining` reaches this number the application will attempt to put heavy-usage subreddits in a **slow mode** where activity processed is slowed to one every 1.5 seconds until the api limit is reset.
|
||||
*
|
||||
@@ -1193,30 +1128,212 @@ export interface OperatorJsonConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperatorConfig extends OperatorJsonConfig {
|
||||
operator: {
|
||||
name: string[]
|
||||
/**
|
||||
* Configuration for application-level settings IE for running the bot instance
|
||||
*
|
||||
* * To load a JSON configuration **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`
|
||||
* * To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`
|
||||
* */
|
||||
export interface OperatorJsonConfig {
|
||||
/**
|
||||
* Mode to run ContextMod in
|
||||
*
|
||||
* * `all` (default) - Run the api and the web interface
|
||||
* * `client` - Run web interface only
|
||||
* * `server` - Run the api/bots only
|
||||
*
|
||||
* @default "all"
|
||||
* */
|
||||
mode?: 'server' | 'client' | 'all',
|
||||
/**
|
||||
* Settings related to the user(s) running this ContextMod instance and information on the bot
|
||||
* */
|
||||
operator?: {
|
||||
/**
|
||||
* The name, or names, of the Reddit accounts, without prefix, that the operators of this bot uses.
|
||||
*
|
||||
* This is used for showing more information in the web interface IE show all logs/subreddits if even not a moderator.
|
||||
*
|
||||
* EX -- User is /u/FoxxMD then `"name": ["FoxxMD"]`
|
||||
*
|
||||
* * ENV => `OPERATOR` (if list, comma-delimited)
|
||||
* * ARG => `--operator <name...>`
|
||||
*
|
||||
* @examples [["FoxxMD","AnotherUser"]]
|
||||
* */
|
||||
name?: string | string[],
|
||||
/**
|
||||
* A **public** name to display to users of the web interface. Use this to help moderators using your bot identify who is the operator in case they need to contact you.
|
||||
*
|
||||
* Leave undefined for no public name to be displayed.
|
||||
*
|
||||
* * ENV => `OPERATOR_DISPLAY`
|
||||
* * ARG => `--operatorDisplay <name>`
|
||||
*
|
||||
* @examples ["Moderators of r/MySubreddit"]
|
||||
* */
|
||||
display?: string,
|
||||
botName?: string,
|
||||
},
|
||||
credentials: {
|
||||
clientId: string,
|
||||
clientSecret: string,
|
||||
redirectUri?: string,
|
||||
accessToken?: string,
|
||||
refreshToken?: string
|
||||
},
|
||||
/**
|
||||
* Settings to configure 3rd party notifications for when ContextMod behavior occurs
|
||||
* */
|
||||
notifications?: NotificationConfig
|
||||
logging: {
|
||||
level: LogLevel,
|
||||
/**
|
||||
* Settings to configure global logging defaults
|
||||
* */
|
||||
logging?: {
|
||||
/**
|
||||
* The minimum log level to output. The log level set will output logs at its level **and all levels above it:**
|
||||
*
|
||||
* * `error`
|
||||
* * `warn`
|
||||
* * `info`
|
||||
* * `verbose`
|
||||
* * `debug`
|
||||
*
|
||||
* Note: `verbose` will display *a lot* of information on the status/result of run rules/checks/actions etc. which is very useful for testing configurations. Once your bot is stable changing the level to `info` will reduce log noise.
|
||||
*
|
||||
* * ENV => `LOG_LEVEL`
|
||||
* * ARG => `--logLevel <level>`
|
||||
*
|
||||
* @default "verbose"
|
||||
* @examples ["verbose"]
|
||||
* */
|
||||
level?: LogLevel,
|
||||
/**
|
||||
* The absolute path to a directory where rotating log files should be stored.
|
||||
*
|
||||
* * If not present or `null` no log files will be created
|
||||
* * If `true` logs will be stored at `[working directory]/logs`
|
||||
*
|
||||
* * ENV => `LOG_DIR`
|
||||
* * ARG => `--logDir [dir]`
|
||||
*
|
||||
* @examples ["/var/log/contextmod"]
|
||||
* */
|
||||
path?: string,
|
||||
},
|
||||
|
||||
bots?: BotInstanceJsonConfig[]
|
||||
|
||||
/**
|
||||
* Settings for the web interface
|
||||
* */
|
||||
web?: {
|
||||
/**
|
||||
* The port for the web interface
|
||||
*
|
||||
* * ENV => `PORT`
|
||||
* * ARG => `--port <number>`
|
||||
*
|
||||
* @default 8085
|
||||
* @examples [8085]
|
||||
* */
|
||||
port?: number,
|
||||
/**
|
||||
* Settings to configure the behavior of user sessions -- the session is what the web interface uses to identify logged in users.
|
||||
* */
|
||||
session?: {
|
||||
/**
|
||||
* The cache provider to use.
|
||||
*
|
||||
* The default should be sufficient for almost all use cases
|
||||
*
|
||||
* @default "memory"
|
||||
* @examples ["memory"]
|
||||
* */
|
||||
provider?: 'memory' | 'redis' | CacheOptions,
|
||||
/**
|
||||
* The secret value used to encrypt session data
|
||||
*
|
||||
* If provider is persistent (redis) specifying a value here will ensure sessions are valid between application restarts
|
||||
*
|
||||
* When not present or `null` a random string is generated on application start
|
||||
*
|
||||
* @examples ["definitelyARandomString"]
|
||||
* */
|
||||
secret?: string,
|
||||
}
|
||||
/**
|
||||
* The default log level to filter to in the web interface
|
||||
*
|
||||
* If not specified or `null` will be same as global `logLevel`
|
||||
* */
|
||||
logLevel?: LogLevel,
|
||||
/**
|
||||
* Maximum number of log statements to keep in memory for each subreddit
|
||||
*
|
||||
* @default 200
|
||||
* @examples [200]
|
||||
* */
|
||||
maxLogs?: number,
|
||||
/**
|
||||
* A list of CM Servers this Client should connect to.
|
||||
*
|
||||
* If not specified a default `BotConnection` for this instance is generated
|
||||
*
|
||||
* @examples [[{"host": "localhost:8095", "secret": "aRandomString"}]]
|
||||
* */
|
||||
clients?: BotConnection[]
|
||||
|
||||
credentials?: WebCredentials
|
||||
|
||||
/**
|
||||
* The name, or names, of the Reddit accounts, without prefix, that the operators of this **web interface** uses.
|
||||
*
|
||||
* **Note:** This is **not the same** as the top-level `operator` property. This allows specified users to see the status of all `clients` but **not** access to them -- that must still be specified in the `operator.name` property in the configuration of each bot.
|
||||
*
|
||||
*
|
||||
* EX -- User is /u/FoxxMD then `"name": ["FoxxMD"]`
|
||||
*
|
||||
* @examples [["FoxxMD","AnotherUser"]]
|
||||
* */
|
||||
operators?: string[]
|
||||
}
|
||||
/**
|
||||
* Configuration for the **Server** application. See [Architecture Documentation](https://github.com/FoxxMD/context-mod/blob/master/docs/serverClientArchitecture.md) for more info
|
||||
* */
|
||||
api?: {
|
||||
/**
|
||||
* The port the server listens on for API requests
|
||||
*
|
||||
* @default 8095
|
||||
* @examples [8095]
|
||||
* */
|
||||
port?: number,
|
||||
/**
|
||||
* The **shared secret** used to verify API requests come from an authenticated client.
|
||||
*
|
||||
* Use this same value for the `secret` value in a `BotConnection` object to connect to this Server
|
||||
* */
|
||||
secret?: string,
|
||||
/**
|
||||
* A friendly name for this server. This will override `friendly` in `BotConnection` if specified.
|
||||
* */
|
||||
friendly?: string,
|
||||
}
|
||||
}
|
||||
|
||||
export interface RequiredOperatorRedditCredentials extends RedditCredentials {
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
}
|
||||
|
||||
export interface RequiredWebRedditCredentials extends RedditCredentials {
|
||||
clientId: string,
|
||||
clientSecret: string
|
||||
redirectUri: string
|
||||
}
|
||||
|
||||
export interface BotInstanceConfig extends BotInstanceJsonConfig {
|
||||
credentials: RequiredOperatorRedditCredentials
|
||||
snoowrap: {
|
||||
proxy?: string,
|
||||
debug?: boolean,
|
||||
}
|
||||
subreddits: {
|
||||
names?: string[],
|
||||
exclude?: string[],
|
||||
dryRun?: boolean,
|
||||
wikiConfig: string,
|
||||
heartbeatInterval: number,
|
||||
@@ -1227,10 +1344,27 @@ export interface OperatorConfig extends OperatorJsonConfig {
|
||||
interval: number,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers: number,
|
||||
maxWorkers: number,
|
||||
},
|
||||
caching: StrongCache,
|
||||
nanny: {
|
||||
softLimit: number,
|
||||
hardLimit: number,
|
||||
}
|
||||
}
|
||||
|
||||
export interface OperatorConfig extends OperatorJsonConfig {
|
||||
mode: 'all' | 'server' | 'client',
|
||||
operator: {
|
||||
name: string[]
|
||||
display?: string,
|
||||
},
|
||||
notifications?: NotificationConfig
|
||||
logging: {
|
||||
level: LogLevel,
|
||||
path?: string,
|
||||
},
|
||||
web: {
|
||||
enabled: boolean,
|
||||
port: number,
|
||||
session: {
|
||||
provider: CacheOptions,
|
||||
@@ -1238,12 +1372,16 @@ export interface OperatorConfig extends OperatorJsonConfig {
|
||||
}
|
||||
logLevel?: LogLevel,
|
||||
maxLogs: number,
|
||||
clients: BotConnection[]
|
||||
credentials: RequiredWebRedditCredentials
|
||||
operators: string[]
|
||||
}
|
||||
caching: StrongCache,
|
||||
api: {
|
||||
softLimit: number,
|
||||
hardLimit: number,
|
||||
port: number,
|
||||
secret: string,
|
||||
friendly?: string,
|
||||
}
|
||||
bots: BotInstanceConfig[]
|
||||
}
|
||||
|
||||
//export type OperatorConfig = Required<OperatorJsonConfig>;
|
||||
@@ -1253,7 +1391,7 @@ interface CacheTypeStat {
|
||||
miss: number,
|
||||
missPercent?: string,
|
||||
identifierRequestCount: Cache
|
||||
identifierAverageHit: number
|
||||
identifierAverageHit: number | string
|
||||
requestTimestamps: number[]
|
||||
averageTimeBetweenHits: string
|
||||
}
|
||||
@@ -1261,3 +1399,14 @@ interface CacheTypeStat {
|
||||
export interface ResourceStats {
|
||||
[key: string]: CacheTypeStat;
|
||||
}
|
||||
|
||||
export interface LogInfo {
|
||||
message: string
|
||||
[MESSAGE]: string,
|
||||
level: string
|
||||
timestamp: string
|
||||
subreddit?: string
|
||||
instance?: string
|
||||
labels?: string[]
|
||||
bot?: string
|
||||
}
|
||||
|
||||
@@ -25,7 +25,13 @@ import {
|
||||
OperatorConfig,
|
||||
PollingOptions,
|
||||
PollingOptionsStrong,
|
||||
PollOn, StrongCache, CacheProvider, CacheOptions
|
||||
PollOn,
|
||||
StrongCache,
|
||||
CacheProvider,
|
||||
CacheOptions,
|
||||
BotInstanceJsonConfig,
|
||||
BotInstanceConfig,
|
||||
RequiredWebRedditCredentials
|
||||
} from "./Common/interfaces";
|
||||
import {isRuleSetJSON, RuleSetJson, RuleSetObjectJson} from "./Rule/RuleSet";
|
||||
import deepEqual from "fast-deep-equal";
|
||||
@@ -246,65 +252,41 @@ export const insertNamedActions = (actions: Array<ActionJson>, namedActions: Map
|
||||
return strongActions;
|
||||
}
|
||||
|
||||
export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
|
||||
export const parseDefaultBotInstanceFromArgs = (args: any): BotInstanceJsonConfig => {
|
||||
const {
|
||||
subreddits,
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
redirectUri,
|
||||
wikiConfig,
|
||||
dryRun,
|
||||
heartbeat,
|
||||
softLimit,
|
||||
heartbeat,
|
||||
hardLimit,
|
||||
authorTTL,
|
||||
operator,
|
||||
operatorDisplay,
|
||||
snooProxy,
|
||||
snooDebug,
|
||||
sharedMod,
|
||||
logLevel,
|
||||
logDir,
|
||||
port,
|
||||
sessionSecret,
|
||||
caching,
|
||||
web
|
||||
} = args || {};
|
||||
|
||||
const data = {
|
||||
operator: {
|
||||
name: operator,
|
||||
display: operatorDisplay
|
||||
},
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
redirectUri,
|
||||
},
|
||||
subreddits: {
|
||||
names: subreddits,
|
||||
wikiConfig,
|
||||
heartbeatInterval: heartbeat,
|
||||
dryRun
|
||||
},
|
||||
logging: {
|
||||
level: logLevel,
|
||||
path: logDir === true ? `${process.cwd()}/logs` : undefined,
|
||||
},
|
||||
snoowrap: {
|
||||
proxy: snooProxy,
|
||||
debug: snooDebug,
|
||||
},
|
||||
web: {
|
||||
enabled: web,
|
||||
port,
|
||||
session: {
|
||||
secret: sessionSecret
|
||||
}
|
||||
subreddits: {
|
||||
names: subreddits,
|
||||
wikiConfig,
|
||||
dryRun,
|
||||
heartbeatInterval: heartbeat,
|
||||
},
|
||||
polling: {
|
||||
sharedMod,
|
||||
@@ -313,11 +295,52 @@ export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
|
||||
provider: caching,
|
||||
authorTTL
|
||||
},
|
||||
api: {
|
||||
nanny: {
|
||||
softLimit,
|
||||
hardLimit
|
||||
}
|
||||
}
|
||||
return removeUndefinedKeys(data) as BotInstanceJsonConfig;
|
||||
}
|
||||
|
||||
export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
|
||||
const {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
operator,
|
||||
operatorDisplay,
|
||||
logLevel,
|
||||
logDir,
|
||||
port,
|
||||
sessionSecret,
|
||||
web,
|
||||
mode,
|
||||
} = args || {};
|
||||
|
||||
const data = {
|
||||
mode,
|
||||
operator: {
|
||||
name: operator,
|
||||
display: operatorDisplay
|
||||
},
|
||||
logging: {
|
||||
level: logLevel,
|
||||
path: logDir === true ? `${process.cwd()}/logs` : undefined,
|
||||
},
|
||||
web: {
|
||||
enabled: web,
|
||||
port,
|
||||
session: {
|
||||
secret: sessionSecret
|
||||
},
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return removeUndefinedKeys(data) as OperatorJsonConfig;
|
||||
}
|
||||
@@ -343,54 +366,65 @@ const parseListFromEnv = (val: string|undefined) => {
|
||||
return listVals;
|
||||
}
|
||||
|
||||
export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
|
||||
export const parseDefaultBotInstanceFromEnv = (): BotInstanceJsonConfig => {
|
||||
const data = {
|
||||
operator: {
|
||||
name: parseListFromEnv(process.env.OPERATOR),
|
||||
display: process.env.OPERATOR_DISPLAY
|
||||
},
|
||||
credentials: {
|
||||
clientId: process.env.CLIENT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
accessToken: process.env.ACCESS_TOKEN,
|
||||
refreshToken: process.env.REFRESH_TOKEN,
|
||||
redirectUri: process.env.REDIRECT_URI,
|
||||
},
|
||||
subreddits: {
|
||||
names: parseListFromEnv(process.env.SUBREDDITS),
|
||||
wikiConfig: process.env.WIKI_CONFIG,
|
||||
heartbeatInterval: process.env.HEARTBEAT !== undefined ? parseInt(process.env.HEARTBEAT) : undefined,
|
||||
dryRun: parseBool(process.env.DRYRUN, undefined),
|
||||
},
|
||||
logging: {
|
||||
// @ts-ignore
|
||||
level: process.env.LOG_LEVEL,
|
||||
path: process.env.LOG_DIR === 'true' ? `${process.cwd()}/logs` : undefined,
|
||||
heartbeatInterval: process.env.HEARTBEAT !== undefined ? parseInt(process.env.HEARTBEAT) : undefined,
|
||||
},
|
||||
snoowrap: {
|
||||
proxy: process.env.PROXY,
|
||||
debug: parseBool(process.env.SNOO_DEBUG, undefined),
|
||||
},
|
||||
web: {
|
||||
enabled: process.env.WEB !== undefined ? parseBool(process.env.WEB) : undefined,
|
||||
port: process.env.PORT !== undefined ? parseInt(process.env.PORT) : undefined,
|
||||
session: {
|
||||
provider: process.env.SESSION_PROVIDER,
|
||||
secret: process.env.SESSION_SECRET
|
||||
}
|
||||
},
|
||||
polling: {
|
||||
sharedMod: parseBool(process.env.SHARE_MOD),
|
||||
},
|
||||
caching: {
|
||||
provider: {
|
||||
// @ts-ignore
|
||||
store: process.env.CACHING as (CacheProvider | undefined)
|
||||
},
|
||||
authorTTL: process.env.AUTHOR_TTL !== undefined ? parseInt(process.env.AUTHOR_TTL) : undefined
|
||||
},
|
||||
api: {
|
||||
nanny: {
|
||||
softLimit: process.env.SOFT_LIMIT !== undefined ? parseInt(process.env.SOFT_LIMIT) : undefined,
|
||||
hardLimit: process.env.HARD_LIMIT !== undefined ? parseInt(process.env.HARD_LIMIT) : undefined
|
||||
},
|
||||
};
|
||||
return removeUndefinedKeys(data) as BotInstanceJsonConfig;
|
||||
}
|
||||
|
||||
export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
|
||||
const data = {
|
||||
mode: process.env.MODE !== undefined ? process.env.MODE as ('all' | 'server' | 'client') : undefined,
|
||||
operator: {
|
||||
name: parseListFromEnv(process.env.OPERATOR),
|
||||
display: process.env.OPERATOR_DISPLAY
|
||||
},
|
||||
logging: {
|
||||
// @ts-ignore
|
||||
level: process.env.LOG_LEVEL,
|
||||
path: process.env.LOG_DIR === 'true' ? `${process.cwd()}/logs` : undefined,
|
||||
},
|
||||
web: {
|
||||
port: process.env.PORT !== undefined ? parseInt(process.env.PORT) : undefined,
|
||||
session: {
|
||||
provider: process.env.SESSION_PROVIDER,
|
||||
secret: process.env.SESSION_SECRET
|
||||
},
|
||||
credentials: {
|
||||
clientId: process.env.CLIENT_ID,
|
||||
clientSecret: process.env.CLIENT_SECRET,
|
||||
redirectUri: process.env.REDIRECT_URI,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -452,63 +486,92 @@ export const parseOperatorConfigFromSources = async (args: any): Promise<Operato
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const configFromArgs = parseOpConfigFromArgs(args);
|
||||
const configFromEnv = parseOpConfigFromEnv();
|
||||
const opConfigFromArgs = parseOpConfigFromArgs(args);
|
||||
const opConfigFromEnv = parseOpConfigFromEnv();
|
||||
|
||||
const mergedConfig = merge.all([configFromEnv, configFromFile, configFromArgs], {
|
||||
const defaultBotInstanceFromArgs = parseDefaultBotInstanceFromArgs(args);
|
||||
const defaultBotInstanceFromEnv = parseDefaultBotInstanceFromEnv();
|
||||
const {bots: botInstancesFromFile = [], ...restConfigFile} = configFromFile;
|
||||
|
||||
const defaultBotInstance = merge.all([defaultBotInstanceFromEnv, defaultBotInstanceFromArgs], {
|
||||
arrayMerge: overwriteMerge,
|
||||
});
|
||||
|
||||
return removeUndefinedKeys(mergedConfig) as OperatorJsonConfig;
|
||||
let botInstances = [];
|
||||
if(botInstancesFromFile.length === 0) {
|
||||
botInstances = [defaultBotInstance];
|
||||
} else {
|
||||
botInstances = botInstancesFromFile.map(x => merge.all([defaultBotInstance, x], {arrayMerge: overwriteMerge}));
|
||||
}
|
||||
|
||||
const mergedConfig = merge.all([opConfigFromEnv, restConfigFile, opConfigFromArgs], {
|
||||
arrayMerge: overwriteMerge,
|
||||
});
|
||||
|
||||
return removeUndefinedKeys({...mergedConfig, bots: botInstances}) as OperatorJsonConfig;
|
||||
}
|
||||
|
||||
export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): OperatorConfig => {
|
||||
const {
|
||||
mode = 'all',
|
||||
operator: {
|
||||
name = [],
|
||||
display = 'Anonymous',
|
||||
botName,
|
||||
} = {},
|
||||
credentials: {
|
||||
clientId: ci,
|
||||
clientSecret: cs,
|
||||
...restCred
|
||||
} = {},
|
||||
subreddits: {
|
||||
names = [],
|
||||
wikiConfig = 'botconfig/contextbot',
|
||||
heartbeatInterval = 300,
|
||||
dryRun
|
||||
} = {},
|
||||
logging: {
|
||||
level = 'verbose',
|
||||
path,
|
||||
} = {},
|
||||
snoowrap = {},
|
||||
web: {
|
||||
enabled = true,
|
||||
port = 8085,
|
||||
maxLogs = 200,
|
||||
session: {
|
||||
secret = randomId(),
|
||||
provider: sessionProvider = { store: 'memory' },
|
||||
} = {}
|
||||
} = {},
|
||||
clients,
|
||||
credentials: webCredentials,
|
||||
operators,
|
||||
} = {},
|
||||
polling: {
|
||||
sharedMod = false,
|
||||
limit = 100,
|
||||
interval = 30,
|
||||
} = {},
|
||||
queue: {
|
||||
maxWorkers = 1,
|
||||
} = {},
|
||||
caching,
|
||||
api: {
|
||||
softLimit = 250,
|
||||
hardLimit = 50
|
||||
port: apiPort = 8095,
|
||||
secret: apiSecret = randomId(),
|
||||
friendly,
|
||||
} = {},
|
||||
bots = [],
|
||||
} = data;
|
||||
|
||||
let hydratedBots: BotInstanceConfig[] = bots.map(x => {
|
||||
const {
|
||||
polling: {
|
||||
sharedMod = false,
|
||||
limit = 100,
|
||||
interval = 30,
|
||||
} = {},
|
||||
queue: {
|
||||
maxWorkers = 1,
|
||||
} = {},
|
||||
caching,
|
||||
nanny: {
|
||||
softLimit = 250,
|
||||
hardLimit = 50
|
||||
} = {},
|
||||
snoowrap = {},
|
||||
credentials: {
|
||||
clientId: ci,
|
||||
clientSecret: cs,
|
||||
...restCred
|
||||
} = {},
|
||||
subreddits: {
|
||||
names = [],
|
||||
exclude = [],
|
||||
wikiConfig = 'botconfig/contextbot',
|
||||
dryRun,
|
||||
heartbeatInterval = 300,
|
||||
} = {},
|
||||
} = x;
|
||||
|
||||
|
||||
let cache: StrongCache;
|
||||
|
||||
if(caching === undefined) {
|
||||
@@ -544,30 +607,49 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
}
|
||||
}
|
||||
|
||||
const config: OperatorConfig = {
|
||||
operator: {
|
||||
name: typeof name === 'string' ? [name] : name,
|
||||
display,
|
||||
botName,
|
||||
return {
|
||||
snoowrap,
|
||||
subreddits: {
|
||||
names,
|
||||
exclude,
|
||||
wikiConfig,
|
||||
heartbeatInterval,
|
||||
dryRun,
|
||||
},
|
||||
credentials: {
|
||||
clientId: (ci as string),
|
||||
clientSecret: (cs as string),
|
||||
...restCred,
|
||||
},
|
||||
caching: cache,
|
||||
polling: {
|
||||
sharedMod,
|
||||
limit,
|
||||
interval,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers,
|
||||
},
|
||||
nanny: {
|
||||
softLimit,
|
||||
hardLimit
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
const defaultOperators = typeof name === 'string' ? [name] : name;
|
||||
|
||||
const config: OperatorConfig = {
|
||||
mode,
|
||||
operator: {
|
||||
name: defaultOperators,
|
||||
display,
|
||||
},
|
||||
logging: {
|
||||
level,
|
||||
path
|
||||
},
|
||||
snoowrap,
|
||||
subreddits: {
|
||||
names,
|
||||
wikiConfig,
|
||||
heartbeatInterval,
|
||||
dryRun,
|
||||
},
|
||||
web: {
|
||||
enabled,
|
||||
port,
|
||||
session: {
|
||||
secret,
|
||||
@@ -582,20 +664,16 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
},
|
||||
},
|
||||
maxLogs,
|
||||
},
|
||||
caching: cache,
|
||||
polling: {
|
||||
sharedMod,
|
||||
limit,
|
||||
interval,
|
||||
},
|
||||
queue: {
|
||||
maxWorkers,
|
||||
clients: clients === undefined ? [{host: 'localhost:8095', secret: apiSecret}] : clients,
|
||||
credentials: webCredentials as RequiredWebRedditCredentials,
|
||||
operators: operators || defaultOperators,
|
||||
},
|
||||
api: {
|
||||
softLimit,
|
||||
hardLimit
|
||||
}
|
||||
port: apiPort,
|
||||
secret: apiSecret,
|
||||
friendly
|
||||
},
|
||||
bots: hydratedBots,
|
||||
};
|
||||
|
||||
return config;
|
||||
|
||||
@@ -6,29 +6,31 @@ import {AttributionJSONConfig, AttributionRule} from "./AttributionRule";
|
||||
import {Logger} from "winston";
|
||||
import HistoryRule, {HistoryJSONConfig} from "./HistoryRule";
|
||||
import RegexRule, {RegexRuleJSONConfig} from "./RegexRule";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import Snoowrap from "snoowrap";
|
||||
|
||||
export function ruleFactory
|
||||
(config: RuleJSONConfig, logger: Logger, subredditName: string): Rule {
|
||||
(config: RuleJSONConfig, logger: Logger, subredditName: string, resources: SubredditResources, client: Snoowrap): Rule {
|
||||
let cfg;
|
||||
switch (config.kind) {
|
||||
case 'recentActivity':
|
||||
cfg = config as RecentActivityRuleJSONConfig;
|
||||
return new RecentActivityRule({...cfg, logger, subredditName});
|
||||
return new RecentActivityRule({...cfg, logger, subredditName, resources, client});
|
||||
case 'repeatActivity':
|
||||
cfg = config as RepeatActivityJSONConfig;
|
||||
return new RepeatActivityRule({...cfg, logger, subredditName});
|
||||
return new RepeatActivityRule({...cfg, logger, subredditName, resources, client});
|
||||
case 'author':
|
||||
cfg = config as AuthorRuleJSONConfig;
|
||||
return new AuthorRule({...cfg, logger, subredditName});
|
||||
return new AuthorRule({...cfg, logger, subredditName, resources, client});
|
||||
case 'attribution':
|
||||
cfg = config as AttributionJSONConfig;
|
||||
return new AttributionRule({...cfg, logger, subredditName});
|
||||
return new AttributionRule({...cfg, logger, subredditName, resources, client});
|
||||
case 'history':
|
||||
cfg = config as HistoryJSONConfig;
|
||||
return new HistoryRule({...cfg, logger, subredditName});
|
||||
return new HistoryRule({...cfg, logger, subredditName, resources, client});
|
||||
case 'regex':
|
||||
cfg = config as RegexRuleJSONConfig;
|
||||
return new RegexRule({...cfg, logger, subredditName});
|
||||
return new RegexRule({...cfg, logger, subredditName, resources, client});
|
||||
default:
|
||||
throw new Error('rule "kind" was not recognized.');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {IRule, Triggerable, Rule, RuleJSONConfig, RuleResult, RuleSetResult} from "./index";
|
||||
import {Comment, Submission} from "snoowrap";
|
||||
import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {ruleFactory} from "./RuleFactory";
|
||||
import {createAjvFactory, mergeArr} from "../util";
|
||||
import {Logger} from "winston";
|
||||
@@ -7,6 +7,7 @@ import {JoinCondition, JoinOperands} from "../Common/interfaces";
|
||||
import * as RuleSchema from '../Schema/Rule.json';
|
||||
import Ajv from 'ajv';
|
||||
import {RuleJson, RuleObjectJson} from "../Common/types";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
|
||||
export class RuleSet implements IRuleSet {
|
||||
rules: Rule[] = [];
|
||||
@@ -24,7 +25,7 @@ export class RuleSet implements IRuleSet {
|
||||
} else {
|
||||
const valid = ajv.validate(RuleSchema, r);
|
||||
if (valid) {
|
||||
this.rules.push(ruleFactory(r as RuleJSONConfig, logger, options.subredditName));
|
||||
this.rules.push(ruleFactory(r as RuleJSONConfig, logger, options.subredditName, options.resources, options.client));
|
||||
} else {
|
||||
this.logger.warn('Could not build rule because of JSON errors', {}, {errors: ajv.errors, obj: r});
|
||||
}
|
||||
@@ -85,6 +86,8 @@ export interface RuleSetOptions extends IRuleSet {
|
||||
rules: Array<IRule | RuleJSONConfig>,
|
||||
logger: Logger
|
||||
subredditName: string
|
||||
resources: SubredditResources
|
||||
client: Snoowrap
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Comment} from "snoowrap";
|
||||
import Snoowrap, {Comment} from "snoowrap";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {Logger} from "winston";
|
||||
import {findResultByPremise, mergeArr} from "../util";
|
||||
import ResourceManager, {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {SubredditResources} from "../Subreddit/SubredditResources";
|
||||
import {ChecksActivityState, TypedActivityStates} from "../Common/interfaces";
|
||||
import Author, {AuthorOptions} from "../Author/Author";
|
||||
|
||||
@@ -12,6 +12,8 @@ export interface RuleOptions {
|
||||
itemIs?: TypedActivityStates;
|
||||
logger: Logger
|
||||
subredditName: string;
|
||||
resources: SubredditResources
|
||||
client: Snoowrap
|
||||
}
|
||||
|
||||
export interface RulePremise {
|
||||
@@ -50,6 +52,7 @@ export abstract class Rule implements IRule, Triggerable {
|
||||
authorIs: AuthorOptions;
|
||||
itemIs: TypedActivityStates;
|
||||
resources: SubredditResources;
|
||||
client: Snoowrap;
|
||||
|
||||
constructor(options: RuleOptions) {
|
||||
const {
|
||||
@@ -61,9 +64,12 @@ export abstract class Rule implements IRule, Triggerable {
|
||||
} = {},
|
||||
itemIs = [],
|
||||
subredditName,
|
||||
resources,
|
||||
client,
|
||||
} = options;
|
||||
this.name = name;
|
||||
this.resources = ResourceManager.get(subredditName) as SubredditResources;
|
||||
this.resources = resources;
|
||||
this.client = client;
|
||||
|
||||
this.authorIs = {
|
||||
exclude: exclude.map(x => new Author(x)),
|
||||
|
||||
@@ -1,6 +1,247 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"BotConnection": {
|
||||
"description": "Configuration required to connect to a CM Server",
|
||||
"properties": {
|
||||
"host": {
|
||||
"description": "The hostname and port the CM Server is listening on EX `localhost:8085`",
|
||||
"type": "string"
|
||||
},
|
||||
"secret": {
|
||||
"description": "The **shared secret** used to sign API calls from the Client to the Server.\n\nThis value should be the same as what is specified in the target CM's `api.secret` configuration",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"host",
|
||||
"secret"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"BotInstanceJsonConfig": {
|
||||
"description": "The configuration for an **individual reddit account** ContextMod will run as a bot.\n\nMultiple bot configs may be specified (one per reddit account).\n\n**NOTE:** If `bots` is not specified in a `FILE` then a default `bot` is generated using `ENV/ARG` values IE `CLIENT_ID`, etc...but if `bots` IS specified the default is not generated.",
|
||||
"properties": {
|
||||
"caching": {
|
||||
"description": "Settings to configure the default caching behavior for each suberddit",
|
||||
"properties": {
|
||||
"authorTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"commentTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a comment should be cached",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"filterCriteriaTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)\n\nThis is especially useful if when polling high-volume comments and your checks rely on author/item filters",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CacheOptions"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"memory",
|
||||
"none",
|
||||
"redis"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "The cache provider and, optionally, a custom configuration for that provider\n\nIf not present or `null` provider will be `memory`.\n\nTo specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`"
|
||||
},
|
||||
"submissionTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a submission should be cached",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"userNotesTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"wikiTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, wiki content pages should be cached",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"credentials": {
|
||||
"$ref": "#/definitions/RedditCredentials",
|
||||
"description": "Credentials required for the bot to interact with Reddit's API\n\nThese credentials will provided to both the API and Web interface unless otherwise specified with the `web.credentials` property\n\nRefer to the [required credentials table](https://github.com/FoxxMD/context-mod/blob/master/docs/operatorConfiguration.md#minimum-required-configuration) to see what is necessary to run the bot.",
|
||||
"examples": [
|
||||
{
|
||||
"accessToken": "p75_1c467b2",
|
||||
"clientId": "f4b4df1_9oiu",
|
||||
"clientSecret": "34v5q1c564_yt7",
|
||||
"redirectUri": "http://localhost:8085/callback",
|
||||
"refreshToken": "34_f1w1v4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"nanny": {
|
||||
"description": "Settings related to managing heavy API usage.",
|
||||
"properties": {
|
||||
"hardLimit": {
|
||||
"default": 50,
|
||||
"description": "When `api limit remaining` reaches this number the application will pause all event polling until the api limit is reset.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"softLimit": {
|
||||
"default": 250,
|
||||
"description": "When `api limit remaining` reaches this number the application will attempt to put heavy-usage subreddits in a **slow mode** where activity processed is slowed to one every 1.5 seconds until the api limit is reset.",
|
||||
"examples": [
|
||||
250
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"notifications": {
|
||||
"$ref": "#/definitions/NotificationConfig",
|
||||
"description": "Settings to configure 3rd party notifications for when behavior occurs"
|
||||
},
|
||||
"polling": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PollingDefaults"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"sharedMod": {
|
||||
"default": false,
|
||||
"description": "If set to `true` all subreddits polling unmoderated/modqueue with default polling settings will share a request to \"r/mod\"\notherwise each subreddit will poll its own mod view\n\n* ENV => `SHARE_MOD`\n* ARG => `--shareMod`",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"description": "Settings related to default polling configurations for subreddits"
|
||||
},
|
||||
"queue": {
|
||||
"description": "Settings related to default configurations for queue behavior for subreddits",
|
||||
"properties": {
|
||||
"maxWorkers": {
|
||||
"default": 1,
|
||||
"description": "Set the number of maximum concurrent workers any subreddit can use.\n\nSubreddits may define their own number of max workers in their config but the application will never allow any subreddit's max workers to be larger than the operator\n\nNOTE: Do not increase this unless you are certain you know what you are doing! The default is suitable for the majority of use cases.",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"snoowrap": {
|
||||
"description": "Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior",
|
||||
"properties": {
|
||||
"debug": {
|
||||
"description": "Manually set the debug status for snoowrap\n\nWhen snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level\n\n* Set to `true` to always output\n* Set to `false` to never output\n\nIf not present or `null` will be set based on `logLevel`\n\n* ENV => `SNOO_DEBUG`\n* ARG => `--snooDebug`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Proxy all requests to Reddit's API through this endpoint\n\n* ENV => `PROXY`\n* ARG => `--proxy <proxyEndpoint>`",
|
||||
"examples": [
|
||||
"http://localhost:4443"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "Settings related to bot behavior for subreddits it is managing",
|
||||
"properties": {
|
||||
"dryRun": {
|
||||
"default": false,
|
||||
"description": "If `true` then all subreddits will run in dry run mode, overriding configurations\n\n* ENV => `DRYRUN`\n* ARG => `--dryRun`",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"exclude": {
|
||||
"description": "Names of subreddits the bot should NOT run, based on what subreddits it moderates\n\nThis setting is ignored if `names` is specified",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"programminghumor"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"heartbeatInterval": {
|
||||
"default": 300,
|
||||
"description": "Interval, in seconds, to perform application heartbeat\n\nOn heartbeat the application does several things:\n\n* Log output with current api rate remaining and other statistics\n* Tries to retrieve and parse configurations for any subreddits with invalid configuration state\n* Restarts any bots stopped/paused due to polling issues, general errors, or invalid configs (if new config is valid)\n\n* ENV => `HEARTBEAT`\n* ARG => `--heartbeat <sec>`",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"names": {
|
||||
"description": "Names of subreddits for bot to run on\n\nIf not present or `null` bot will run on all subreddits it is a moderator of\n\n* ENV => `SUBREDDITS` (comma-separated)\n* ARG => `--subreddits <list...>`",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"programminghumor"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"wikiConfig": {
|
||||
"default": "botconfig/contextbot",
|
||||
"description": "The default relative url to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`\n\n* ENV => `WIKI_CONFIG`\n* ARG => `--wikiConfig <path>`",
|
||||
"examples": [
|
||||
"botconfig/contextbot"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CacheOptions": {
|
||||
"description": "Configure granular settings for a cache provider with this object",
|
||||
"properties": {
|
||||
@@ -177,104 +418,18 @@
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "Configuration for application-level settings IE for running the bot instance\n\n* To load a JSON configuration **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`\n* To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`",
|
||||
"properties": {
|
||||
"api": {
|
||||
"description": "Settings related to managing heavy API usage.",
|
||||
"properties": {
|
||||
"hardLimit": {
|
||||
"default": 50,
|
||||
"description": "When `api limit remaining` reaches this number the application will pause all event polling until the api limit is reset.",
|
||||
"examples": [
|
||||
50
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"softLimit": {
|
||||
"default": 250,
|
||||
"description": "When `api limit remaining` reaches this number the application will attempt to put heavy-usage subreddits in a **slow mode** where activity processed is slowed to one every 1.5 seconds until the api limit is reset.",
|
||||
"examples": [
|
||||
250
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"caching": {
|
||||
"description": "Settings to configure the default caching behavior for each suberddit",
|
||||
"properties": {
|
||||
"authorTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, author activity history (Comments/Submission) should be cached\n\n* ENV => `AUTHOR_TTL`\n* ARG => `--authorTTL <sec>`",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"commentTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a comment should be cached",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"filterCriteriaTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, to cache filter criteria results (`authorIs` and `itemIs` results)\n\nThis is especially useful if when polling high-volume comments and your checks rely on author/item filters",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/CacheOptions"
|
||||
},
|
||||
{
|
||||
"enum": [
|
||||
"memory",
|
||||
"none",
|
||||
"redis"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"description": "The cache provider and, optionally, a custom configuration for that provider\n\nIf not present or `null` provider will be `memory`.\n\nTo specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`"
|
||||
},
|
||||
"submissionTTL": {
|
||||
"default": 60,
|
||||
"description": "Amount of time, in seconds, a submission should be cached",
|
||||
"examples": [
|
||||
60
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"userNotesTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, [Toolbox User Notes](https://www.reddit.com/r/toolbox/wiki/docs/usernotes) should be cached",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"wikiTTL": {
|
||||
"default": 300,
|
||||
"description": "Amount of time, in seconds, wiki content pages should be cached",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": "number"
|
||||
"RedditCredentials": {
|
||||
"description": "Credentials required for the bot to interact with Reddit's API\n\nThese credentials will provided to both the API and Web interface unless otherwise specified with the `web.credentials` property\n\nRefer to the [required credentials table](https://github.com/FoxxMD/context-mod/blob/master/docs/operatorConfiguration.md#minimum-required-configuration) to see what is necessary to run the bot.",
|
||||
"examples": [
|
||||
{
|
||||
"accessToken": "p75_1c467b2",
|
||||
"clientId": "f4b4df1_9oiu",
|
||||
"clientSecret": "34v5q1c564_yt7",
|
||||
"redirectUri": "http://localhost:8085/callback",
|
||||
"refreshToken": "34_f1w1v4"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"credentials": {
|
||||
"description": "The credentials required for the bot to interact with Reddit's API\n\n**Note:** Only `clientId` and `clientSecret` are required for initial setup (to use the oauth helper) **but ALL are required to properly run the bot.**",
|
||||
],
|
||||
"properties": {
|
||||
"accessToken": {
|
||||
"description": "Access token retrieved from authenticating an account with your Reddit Application\n\n* ENV => `ACCESS_TOKEN`\n* ARG => `--accessToken <token>`",
|
||||
@@ -297,14 +452,6 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"redirectUri": {
|
||||
"description": "Redirect URI for your Reddit application\n\nOnly required if running ContextMod with a web interface (and after using oauth helper)\n\n* ENV => `REDIRECT_URI`\n* ARG => `--redirectUri <uri>`",
|
||||
"examples": [
|
||||
"http://localhost:8085"
|
||||
],
|
||||
"format": "uri",
|
||||
"type": "string"
|
||||
},
|
||||
"refreshToken": {
|
||||
"description": "Refresh token retrieved from authenticating an account with your Reddit Application\n\n* ENV => `REFRESH_TOKEN`\n* ARG => `--refreshToken <token>`",
|
||||
"examples": [
|
||||
@@ -315,6 +462,71 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"WebCredentials": {
|
||||
"description": "Separate credentials for the web interface can be provided when also running the api.\n\nAll properties not specified will default to values given in ENV/ARG credential properties\n\nRefer to the [required credentials table](https://github.com/FoxxMD/context-mod/blob/master/docs/operatorConfiguration.md#minimum-required-configuration) to see what is necessary for the web interface.",
|
||||
"examples": [
|
||||
{
|
||||
"clientId": "f4b4df1_9oiu",
|
||||
"clientSecret": "34v5q1c564_yt7",
|
||||
"redirectUri": "http://localhost:8085/callback"
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"clientId": {
|
||||
"description": "Client ID for your Reddit application",
|
||||
"examples": [
|
||||
"f4b4df1_9oiu"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"clientSecret": {
|
||||
"description": "Client Secret for your Reddit application",
|
||||
"examples": [
|
||||
"34v5q1c564_yt7"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"redirectUri": {
|
||||
"description": "Redirect URI for your Reddit application\n\nUsed for:\n\n* accessing the web interface for monitoring bots\n* authenticating an account to use for a bot instance\n\n* ENV => `REDIRECT_URI`\n* ARG => `--redirectUri <uri>`",
|
||||
"examples": [
|
||||
"http://localhost:8085/callback"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"description": "Configuration for application-level settings IE for running the bot instance\n\n* To load a JSON configuration **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`\n* To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`",
|
||||
"properties": {
|
||||
"api": {
|
||||
"description": "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.",
|
||||
"type": "string"
|
||||
},
|
||||
"port": {
|
||||
"default": 8095,
|
||||
"description": "The port the server listens on for API requests",
|
||||
"examples": [
|
||||
8095
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"secret": {
|
||||
"description": "The **shared secret** used to verify API requests come from an authenticated client.\n\nUse this same value for the `secret` value in a `BotConnection` object to connect to this Server",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"bots": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/BotInstanceJsonConfig"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"logging": {
|
||||
"description": "Settings to configure global logging defaults",
|
||||
"properties": {
|
||||
@@ -343,6 +555,16 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"mode": {
|
||||
"default": "all",
|
||||
"description": "Mode to run ContextMod in\n\n* `all` (default) - Run the api and the web interface\n* `client` - Run web interface only\n* `server` - Run the api/bots only",
|
||||
"enum": [
|
||||
"all",
|
||||
"client",
|
||||
"server"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"notifications": {
|
||||
"$ref": "#/definitions/NotificationConfig",
|
||||
"description": "Settings to configure 3rd party notifications for when ContextMod behavior occurs"
|
||||
@@ -350,13 +572,6 @@
|
||||
"operator": {
|
||||
"description": "Settings related to the user(s) running this ContextMod instance and information on the bot",
|
||||
"properties": {
|
||||
"botName": {
|
||||
"description": "The name to use when identifying the bot. Defaults to name of the authenticated Reddit account IE `u/yourBotAccount`",
|
||||
"examples": [
|
||||
"u/yourBotAccount"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"display": {
|
||||
"description": "A **public** name to display to users of the web interface. Use this to help moderators using your bot identify who is the operator in case they need to contact you.\n\nLeave undefined for no public name to be displayed.\n\n* ENV => `OPERATOR_DISPLAY`\n* ARG => `--operatorDisplay <name>`",
|
||||
"examples": [
|
||||
@@ -387,105 +602,34 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"polling": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PollingDefaults"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"sharedMod": {
|
||||
"default": false,
|
||||
"description": "If set to `true` all subreddits polling unmoderated/modqueue with default polling settings will share a request to \"r/mod\"\notherwise each subreddit will poll its own mod view\n\n* ENV => `SHARE_MOD`\n* ARG => `--shareMod`",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
],
|
||||
"description": "Settings related to default polling configurations for subreddits"
|
||||
},
|
||||
"queue": {
|
||||
"description": "Settings related to default configurations for queue behavior for subreddits",
|
||||
"properties": {
|
||||
"maxWorkers": {
|
||||
"default": 1,
|
||||
"description": "Set the number of maximum concurrent workers any subreddit can use.\n\nSubreddits may define their own number of max workers in their config but the application will never allow any subreddit's max workers to be larger than the operator\n\nNOTE: Do not increase this unless you are certain you know what you are doing! The default is suitable for the majority of use cases.",
|
||||
"examples": [
|
||||
1
|
||||
],
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"snoowrap": {
|
||||
"description": "Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior",
|
||||
"properties": {
|
||||
"debug": {
|
||||
"description": "Manually set the debug status for snoowrap\n\nWhen snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level\n\n* Set to `true` to always output\n* Set to `false` to never output\n\nIf not present or `null` will be set based on `logLevel`\n\n* ENV => `SNOO_DEBUG`\n* ARG => `--snooDebug`",
|
||||
"type": "boolean"
|
||||
},
|
||||
"proxy": {
|
||||
"description": "Proxy all requests to Reddit's API through this endpoint\n\n* ENV => `PROXY`\n* ARG => `--proxy <proxyEndpoint>`",
|
||||
"examples": [
|
||||
"http://localhost:4443"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"subreddits": {
|
||||
"description": "Settings related to bot behavior for subreddits it is managing",
|
||||
"properties": {
|
||||
"dryRun": {
|
||||
"default": false,
|
||||
"description": "If `true` then all subreddits will run in dry run mode, overriding configurations\n\n* ENV => `DRYRUN`\n* ARG => `--dryRun`",
|
||||
"examples": [
|
||||
false
|
||||
],
|
||||
"type": "boolean"
|
||||
},
|
||||
"heartbeatInterval": {
|
||||
"default": 300,
|
||||
"description": "Interval, in seconds, to perform application heartbeat\n\nOn heartbeat the application does several things:\n\n* Log output with current api rate remaining and other statistics\n* Tries to retrieve and parse configurations for any subreddits with invalid configuration state\n* Restarts any bots stopped/paused due to polling issues, general errors, or invalid configs (if new config is valid)\n\n* ENV => `HEARTBEAT`\n* ARG => `--heartbeat <sec>`",
|
||||
"examples": [
|
||||
300
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"names": {
|
||||
"description": "Names of subreddits for bot to run on\n\nIf not present or `null` bot will run on all subreddits it is a moderator of\n\n* ENV => `SUBREDDITS` (comma-separated)\n* ARG => `--subreddits <list...>`",
|
||||
"examples": [
|
||||
[
|
||||
"mealtimevideos",
|
||||
"programminghumor"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"wikiConfig": {
|
||||
"default": "botconfig/contextbot",
|
||||
"description": "The default relative url to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`\n\n* ENV => `WIKI_CONFIG`\n* ARG => `--wikiConfig <path>`",
|
||||
"examples": [
|
||||
"botconfig/contextbot"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"web": {
|
||||
"description": "Settings for the web interface",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"default": true,
|
||||
"description": "Whether the web server interface should be started\n\nIn most cases this does not need to be specified as the application will automatically detect if it is possible to start it --\nuse this to specify \"cli only\" behavior if you encounter errors with port/address or are paranoid\n\n* ENV => `WEB`\n* ARG => `node src/index.js run [interface]` -- interface can be `web` or `cli`",
|
||||
"type": "boolean"
|
||||
"clients": {
|
||||
"description": "A list of CM Servers this Client should connect to.\n\nIf not specified a default `BotConnection` for this instance is generated",
|
||||
"examples": [
|
||||
[
|
||||
{
|
||||
"host": "localhost:8095",
|
||||
"secret": "aRandomString"
|
||||
}
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/definitions/BotConnection"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"credentials": {
|
||||
"$ref": "#/definitions/WebCredentials",
|
||||
"description": "Separate credentials for the web interface can be provided when also running the api.\n\nAll properties not specified will default to values given in ENV/ARG credential properties\n\nRefer to the [required credentials table](https://github.com/FoxxMD/context-mod/blob/master/docs/operatorConfiguration.md#minimum-required-configuration) to see what is necessary for the web interface.",
|
||||
"examples": [
|
||||
{
|
||||
"clientId": "f4b4df1_9oiu",
|
||||
"clientSecret": "34v5q1c564_yt7",
|
||||
"redirectUri": "http://localhost:8085/callback"
|
||||
}
|
||||
]
|
||||
},
|
||||
"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`",
|
||||
@@ -506,6 +650,19 @@
|
||||
],
|
||||
"type": "number"
|
||||
},
|
||||
"operators": {
|
||||
"description": "The name, or names, of the Reddit accounts, without prefix, that the operators of this **web interface** uses.\n\n**Note:** This is **not the same** as the top-level `operator` property. This allows specified users to see the status of all `clients` but **not** access to them -- that must still be specified in the `operator.name` property in the configuration of each bot.\n\n\nEX -- User is /u/FoxxMD then `\"name\": [\"FoxxMD\"]`",
|
||||
"examples": [
|
||||
[
|
||||
"FoxxMD",
|
||||
"AnotherUser"
|
||||
]
|
||||
],
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"port": {
|
||||
"default": 8085,
|
||||
"description": "The port for the web interface\n\n* ENV => `PORT`\n* ARG => `--port <number>`",
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
import {addAsync, Router} from '@awaitjs/express';
|
||||
import express from 'express';
|
||||
import Snoowrap from "snoowrap";
|
||||
import {permissions} from "../util";
|
||||
import {getLogger} from "../Utils/loggerFactory";
|
||||
import {OperatorConfig} from "../Common/interfaces";
|
||||
|
||||
const app = addAsync(express());
|
||||
const router = Router();
|
||||
app.set('views', `${__dirname}/views`);
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
app.use(router);
|
||||
|
||||
const helperServer = async function (options: OperatorConfig) {
|
||||
let rUri: string;
|
||||
|
||||
const {
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
},
|
||||
web: {
|
||||
port
|
||||
}
|
||||
} = options;
|
||||
|
||||
const server = await app.listen(port);
|
||||
const logger = getLogger(options);
|
||||
logger.info(`Helper UI started: http://localhost:${port}`);
|
||||
app.getAsync('/', async (req, res) => {
|
||||
res.render('helper', {
|
||||
redirectUri
|
||||
});
|
||||
});
|
||||
|
||||
app.getAsync('/auth', async (req, res) => {
|
||||
rUri = req.query.redirect as string;
|
||||
let permissionsList = permissions;
|
||||
|
||||
const includeWikiEdit = (req.query.wikiEdit as any).toString() === "1";
|
||||
if (!includeWikiEdit) {
|
||||
permissionsList = permissionsList.filter(x => x !== 'wikiedit');
|
||||
}
|
||||
const authUrl = Snoowrap.getAuthUrl({
|
||||
clientId,
|
||||
scope: permissionsList,
|
||||
redirectUri: rUri as string,
|
||||
permanent: true,
|
||||
});
|
||||
return res.redirect(authUrl);
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async (req, res) => {
|
||||
const {error, code} = req.query as any;
|
||||
if (error !== undefined) {
|
||||
let errContent: string;
|
||||
switch (error) {
|
||||
case 'access_denied':
|
||||
errContent = 'You must <b>Allow</b> this application to connect in order to proceed.';
|
||||
break;
|
||||
default:
|
||||
errContent = error;
|
||||
}
|
||||
return res.render('error', {error: errContent, });
|
||||
}
|
||||
const client = await Snoowrap.fromAuthCode({
|
||||
userAgent: `web:contextBot:web`,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: rUri,
|
||||
code: code as string,
|
||||
});
|
||||
// @ts-ignore
|
||||
const user = await client.getMe();
|
||||
|
||||
res.render('callback', {
|
||||
accessToken: client.accessToken,
|
||||
refreshToken: client.refreshToken,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default helperServer;
|
||||
@@ -1,21 +0,0 @@
|
||||
/* adapted from https://cdn.jsdelivr.net/npm/pretty-print-json@1.0/dist/pretty-print-json.css */
|
||||
.json-key { color: brown; }
|
||||
.json-string { color: olive; }
|
||||
.json-number { color: navy; }
|
||||
.json-boolean { color: teal; }
|
||||
.json-null { color: dimgray; }
|
||||
.json-mark { color: black; }
|
||||
a.json-link { color: purple; transition: all 400ms; }
|
||||
a.json-link:visited { color: slategray; }
|
||||
a.json-link:hover { color: blueviolet; }
|
||||
a.json-link:active { color: slategray; }
|
||||
.dark .json-key { color: indianred; }
|
||||
.dark .json-string { color: darkkhaki; }
|
||||
.dark .json-number { color: deepskyblue; }
|
||||
.dark .json-boolean { color: mediumseagreen; }
|
||||
.dark .json-null { color: darkorange; }
|
||||
.dark .json-mark { color: silver; }
|
||||
.dark a.json-link { color: mediumorchid; }
|
||||
.dark a.json-link:visited { color: slategray; }
|
||||
.dark a.json-link:hover { color: violet; }
|
||||
.dark a.json-link:active { color: silver; }
|
||||
@@ -1,787 +0,0 @@
|
||||
import {addAsync, Router} from '@awaitjs/express';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import session from 'express-session';
|
||||
import {Cache} from 'cache-manager';
|
||||
// @ts-ignore
|
||||
import CacheManagerStore from 'express-session-cache-manager'
|
||||
import Snoowrap from "snoowrap";
|
||||
import {App} from "../App";
|
||||
import dayjs from 'dayjs';
|
||||
import {Writable} from "stream";
|
||||
import winston from 'winston';
|
||||
import {Server as SocketServer} from 'socket.io';
|
||||
import sharedSession from 'express-socket.io-session';
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import EventEmitter from "events";
|
||||
import tcpUsed from 'tcp-port-used';
|
||||
import { prettyPrintJson } from 'pretty-print-json';
|
||||
|
||||
import {
|
||||
boolToString, cacheStats,
|
||||
COMMENT_URL_ID, createCacheManager,
|
||||
filterLogBySubreddit,
|
||||
formatLogLineToHtml, formatNumber,
|
||||
isLogLineMinLevel,
|
||||
LogEntry, parseFromJsonOrYamlToObject,
|
||||
parseLinkIdentifier,
|
||||
parseSubredditLogName, parseSubredditName,
|
||||
pollingInfo, SUBMISSION_URL_ID
|
||||
} from "../util";
|
||||
import {Manager} from "../Subreddit/Manager";
|
||||
import {getLogger} from "../Utils/loggerFactory";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {OperatorConfig, ResourceStats, RUNNING, STOPPED, SYSTEM, USER} from "../Common/interfaces";
|
||||
import http from "http";
|
||||
import SimpleError from "../Utils/SimpleError";
|
||||
|
||||
const app = addAsync(express());
|
||||
const router = Router();
|
||||
|
||||
app.use(router);
|
||||
app.use(bodyParser.json());
|
||||
app.set('views', `${__dirname}/views`);
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
interface ConnectedUserInfo {
|
||||
subreddits: string[],
|
||||
level?: string,
|
||||
user: string
|
||||
}
|
||||
|
||||
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
|
||||
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
|
||||
|
||||
const connectedUsers: Map<string, ConnectedUserInfo> = new Map();
|
||||
|
||||
const availableLevels = ['error', 'warn', 'info', 'verbose', 'debug'];
|
||||
|
||||
let operatorSessionIds: string[] = [];
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user: string,
|
||||
subreddits: string[],
|
||||
lastCheck?: number,
|
||||
limit?: number,
|
||||
sort?: string,
|
||||
level?: string,
|
||||
}
|
||||
}
|
||||
|
||||
const subLogMap: Map<string, LogEntry[]> = new Map();
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const stream = new Writable()
|
||||
|
||||
const rcbServer = function (options: OperatorConfig): ([() => Promise<void>, App]) {
|
||||
|
||||
const {
|
||||
credentials: {
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri
|
||||
},
|
||||
operator: {
|
||||
name,
|
||||
display,
|
||||
},
|
||||
web: {
|
||||
port,
|
||||
session: {
|
||||
provider,
|
||||
secret,
|
||||
},
|
||||
maxLogs,
|
||||
},
|
||||
} = options;
|
||||
|
||||
const opNames = name.map(x => x.toLowerCase());
|
||||
let bot: App;
|
||||
let botSubreddits: string[] = [];
|
||||
|
||||
stream._write = (chunk, encoding, next) => {
|
||||
// remove newline (\n) from end of string since we deal with it with css/html
|
||||
const logLine = chunk.toString().slice(0, -1);
|
||||
const now = Date.now();
|
||||
const logEntry: LogEntry = [now, logLine];
|
||||
|
||||
const subName = parseSubredditLogName(logLine);
|
||||
if (subName !== undefined && (botSubreddits.length === 0 || botSubreddits.includes(subName))) {
|
||||
const subLogs = subLogMap.get(subName) || [];
|
||||
subLogs.unshift(logEntry);
|
||||
subLogMap.set(subName, subLogs.slice(0, maxLogs + 1));
|
||||
} else {
|
||||
const appLogs = subLogMap.get('app') || [];
|
||||
appLogs.unshift(logEntry);
|
||||
subLogMap.set('app', appLogs.slice(0, maxLogs + 1));
|
||||
}
|
||||
|
||||
emitter.emit('log', logLine);
|
||||
next();
|
||||
}
|
||||
const streamTransport = new winston.transports.Stream({
|
||||
stream,
|
||||
})
|
||||
|
||||
const logger = getLogger({...options.logging, additionalTransports: [streamTransport]})
|
||||
|
||||
// need to return App to main so that we can handle app shutdown on SIGTERM and discriminate between normal shutdown and crash on error
|
||||
bot = new App(options);
|
||||
|
||||
const serverFunc = async function () {
|
||||
|
||||
if (await tcpUsed.check(port)) {
|
||||
throw new SimpleError(`Specified port for web interface (${port}) is in use or not available. Cannot start web server.`);
|
||||
}
|
||||
|
||||
let server: http.Server,
|
||||
io: SocketServer;
|
||||
|
||||
try {
|
||||
server = await app.listen(port);
|
||||
io = new SocketServer(server);
|
||||
} catch (err) {
|
||||
logger.error('Error occurred while initializing web or socket.io server', err);
|
||||
err.logged = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info(`Web UI started: http://localhost:${port}`);
|
||||
|
||||
await bot.testClient();
|
||||
|
||||
app.use('/public', express.static(`${__dirname}/public`));
|
||||
|
||||
await bot.buildManagers();
|
||||
botSubreddits = bot.subManagers.map(x => x.displayLabel);
|
||||
// TODO potentially prune subLogMap of user keys? shouldn't have happened this early though
|
||||
|
||||
if (provider.store === 'none') {
|
||||
logger.warn(`Cannot use 'none' for session store or else no one can use the interface...falling back to 'memory'`);
|
||||
provider.store = 'memory';
|
||||
}
|
||||
const sessionObj = session({
|
||||
cookie: {
|
||||
maxAge: provider.ttl,
|
||||
},
|
||||
store: new CacheManagerStore(createCacheManager(provider) as Cache),
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
secret,
|
||||
});
|
||||
|
||||
app.use(sessionObj);
|
||||
io.use(sharedSession(sessionObj));
|
||||
|
||||
io.on("connection", function (socket) {
|
||||
// @ts-ignore
|
||||
if (socket.handshake.session.user !== undefined) {
|
||||
// @ts-ignore
|
||||
socket.join(socket.handshake.session.id);
|
||||
// @ts-ignore
|
||||
connectedUsers.set(socket.handshake.session.id, {
|
||||
// @ts-ignore
|
||||
subreddits: socket.handshake.session.subreddits,
|
||||
// @ts-ignore
|
||||
level: socket.handshake.session.level,
|
||||
// @ts-ignore
|
||||
user: socket.handshake.session.user
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
if (opNames.includes(socket.handshake.session.user.toLowerCase())) {
|
||||
// @ts-ignore
|
||||
operatorSessionIds.push(socket.handshake.session.id)
|
||||
}
|
||||
}
|
||||
});
|
||||
io.on('disconnect', (socket) => {
|
||||
// @ts-ignore
|
||||
connectedUsers.delete(socket.handshake.session.id);
|
||||
operatorSessionIds = operatorSessionIds.filter(x => x !== socket.handshake.session.id)
|
||||
});
|
||||
|
||||
const redditUserMiddleware = async (req: express.Request, res: express.Response, next: Function) => {
|
||||
if (req.session.user === undefined) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
const booleanMiddle = (boolParams: string[] = []) => async (req: express.Request, res: express.Response, next: Function) => {
|
||||
if (req.query !== undefined) {
|
||||
for (const b of boolParams) {
|
||||
const bVal = req.query[b] as any;
|
||||
if (bVal !== undefined) {
|
||||
let truthyVal: boolean;
|
||||
if (bVal === 'true' || bVal === true || bVal === 1 || bVal === '1') {
|
||||
truthyVal = true;
|
||||
} else if (bVal === 'false' || bVal === false || bVal === 0 || bVal === '0') {
|
||||
truthyVal = false;
|
||||
} else {
|
||||
res.status(400);
|
||||
res.send(`Expected query parameter ${b} to be a truthy value. Got "${bVal}" but must be one of these: true/false, 1/0`);
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
req.query[b] = truthyVal;
|
||||
}
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
app.getAsync('/logout', async (req, res) => {
|
||||
// @ts-ignore
|
||||
req.session.destroy();
|
||||
res.send('Bye!');
|
||||
})
|
||||
|
||||
app.getAsync('/login', async (req, res) => {
|
||||
if (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 authUrl = Snoowrap.getAuthUrl({
|
||||
clientId,
|
||||
scope: ['identity', 'mysubreddits'],
|
||||
redirectUri: redirectUri as string,
|
||||
permanent: false,
|
||||
});
|
||||
return res.redirect(authUrl);
|
||||
});
|
||||
|
||||
app.getAsync(/.*callback$/, async (req, res) => {
|
||||
const {error, code} = req.query as any;
|
||||
if (error !== undefined) {
|
||||
let errContent: string;
|
||||
switch (error) {
|
||||
case 'access_denied':
|
||||
errContent = 'You must <b>Allow</b> this application to connect in order to proceed.';
|
||||
break;
|
||||
default:
|
||||
errContent = error;
|
||||
}
|
||||
return res.render('error', {error: errContent, operatorDisplay: display});
|
||||
}
|
||||
const client = await Snoowrap.fromAuthCode({
|
||||
userAgent: `web:contextBot:web`,
|
||||
clientId,
|
||||
clientSecret,
|
||||
redirectUri: redirectUri as string,
|
||||
code: code as string,
|
||||
});
|
||||
// @ts-ignore
|
||||
const user = await client.getMe().name as string;
|
||||
const subs = await client.getModeratedSubreddits();
|
||||
|
||||
req.session['user'] = user;
|
||||
// @ts-ignore
|
||||
req.session['subreddits'] = opNames.includes(user.toLowerCase()) ? bot.subManagers.map(x => x.displayLabel) : subs.reduce((acc: string[], x) => {
|
||||
const sm = bot.subManagers.find(y => y.subreddit.display_name === x.display_name);
|
||||
if (sm !== undefined) {
|
||||
return acc.concat(sm.displayLabel);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
req.session['lastCheck'] = dayjs().unix();
|
||||
res.redirect('/');
|
||||
});
|
||||
|
||||
app.use('/', redditUserMiddleware);
|
||||
app.getAsync('/', async (req, res) => {
|
||||
const {
|
||||
subreddits = [],
|
||||
user: userVal,
|
||||
limit = 200,
|
||||
level = 'verbose',
|
||||
sort = 'descending',
|
||||
lastCheck
|
||||
} = req.session;
|
||||
const user = userVal as string;
|
||||
const isOperator = opNames.includes(user.toLowerCase())
|
||||
|
||||
if ((req.session.subreddits as string[]).length === 0 && !isOperator) {
|
||||
return res.render('noSubs', {operatorDisplay: display});
|
||||
}
|
||||
|
||||
const logs = filterLogBySubreddit(subLogMap, req.session.subreddits, {
|
||||
level,
|
||||
operator: isOperator,
|
||||
user,
|
||||
// @ts-ignore
|
||||
sort,
|
||||
limit
|
||||
});
|
||||
|
||||
const subManagerData = [];
|
||||
for (const s of subreddits) {
|
||||
const m = bot.subManagers.find(x => x.displayLabel === s) as Manager;
|
||||
const sd = {
|
||||
name: s,
|
||||
//linkName: s.replace(/\W/g, ''),
|
||||
logs: logs.get(s) || [], // provide a default empty value in case we truly have not logged anything for this subreddit yet
|
||||
botState: m.botState,
|
||||
eventsState: m.eventsState,
|
||||
queueState: m.queueState,
|
||||
indicator: 'gray',
|
||||
queuedActivities: m.queue.length(),
|
||||
runningActivities: m.queue.running(),
|
||||
maxWorkers: m.queue.concurrency,
|
||||
subMaxWorkers: m.subMaxWorkers || bot.maxWorkers,
|
||||
globalMaxWorkers: bot.maxWorkers,
|
||||
validConfig: boolToString(m.validConfigLoaded),
|
||||
dryRun: boolToString(m.dryRun === true),
|
||||
pollingInfo: m.pollOptions.length === 0 ? ['nothing :('] : m.pollOptions.map(pollingInfo),
|
||||
checks: {
|
||||
submissions: m.submissionChecks === undefined ? 0 : m.submissionChecks.length,
|
||||
comments: m.commentChecks === undefined ? 0 : m.commentChecks.length,
|
||||
},
|
||||
wikiLocation: m.wikiLocation,
|
||||
wikiHref: `https://reddit.com/r/${m.subreddit.display_name}/wiki/${m.wikiLocation}`,
|
||||
wikiRevisionHuman: m.lastWikiRevision === undefined ? 'N/A' : `${dayjs.duration(dayjs().diff(m.lastWikiRevision)).humanize()} ago`,
|
||||
wikiRevision: m.lastWikiRevision === undefined ? 'N/A' : m.lastWikiRevision.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
wikiLastCheckHuman: `${dayjs.duration(dayjs().diff(m.lastWikiCheck)).humanize()} ago`,
|
||||
wikiLastCheck: m.lastWikiCheck.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
stats: await m.getStats(),
|
||||
startedAt: 'Not Started',
|
||||
startedAtHuman: 'Not Started',
|
||||
delayBy: m.delayBy === undefined ? 'No' : `Delayed by ${m.delayBy} sec`,
|
||||
};
|
||||
// TODO replace indicator data with js on client page
|
||||
let indicator;
|
||||
if (m.botState.state === RUNNING && m.queueState.state === RUNNING && m.eventsState.state === RUNNING) {
|
||||
indicator = 'green';
|
||||
} else if (m.botState.state === STOPPED && m.queueState.state === STOPPED && m.eventsState.state === STOPPED) {
|
||||
indicator = 'red';
|
||||
} else {
|
||||
indicator = 'yellow';
|
||||
}
|
||||
sd.indicator = indicator;
|
||||
if (m.startedAt !== undefined) {
|
||||
const dur = dayjs.duration(dayjs().diff(m.startedAt));
|
||||
sd.startedAtHuman = `${dur.humanize()} ago`;
|
||||
sd.startedAt = m.startedAt.local().format('MMMM D, YYYY h:mm A Z');
|
||||
|
||||
if (sd.stats.cache.totalRequests > 0) {
|
||||
const minutes = dur.asMinutes();
|
||||
if (minutes < 10) {
|
||||
sd.stats.cache.requestRate = formatNumber((10 / minutes) * sd.stats.cache.totalRequests, {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
} else {
|
||||
sd.stats.cache.requestRate = formatNumber(sd.stats.cache.totalRequests / (minutes / 10), {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sd.stats.cache.requestRate = 0;
|
||||
}
|
||||
}
|
||||
subManagerData.push(sd);
|
||||
}
|
||||
const totalStats = subManagerData.reduce((acc, curr) => {
|
||||
return {
|
||||
checks: {
|
||||
submissions: acc.checks.submissions + curr.checks.submissions,
|
||||
comments: acc.checks.comments + curr.checks.comments,
|
||||
},
|
||||
eventsCheckedTotal: acc.eventsCheckedTotal + curr.stats.eventsCheckedTotal,
|
||||
checksRunTotal: acc.checksRunTotal + curr.stats.checksRunTotal,
|
||||
checksTriggeredTotal: acc.checksTriggeredTotal + curr.stats.checksTriggeredTotal,
|
||||
rulesRunTotal: acc.rulesRunTotal + curr.stats.rulesRunTotal,
|
||||
rulesCachedTotal: acc.rulesCachedTotal + curr.stats.rulesCachedTotal,
|
||||
rulesTriggeredTotal: acc.rulesTriggeredTotal + curr.stats.rulesTriggeredTotal,
|
||||
actionsRunTotal: acc.actionsRunTotal + curr.stats.actionsRunTotal,
|
||||
maxWorkers: acc.maxWorkers + curr.maxWorkers,
|
||||
subMaxWorkers: acc.subMaxWorkers + curr.subMaxWorkers,
|
||||
globalMaxWorkers: acc.globalMaxWorkers + curr.globalMaxWorkers,
|
||||
runningActivities: acc.runningActivities + curr.runningActivities,
|
||||
queuedActivities: acc.queuedActivities + curr.queuedActivities,
|
||||
};
|
||||
}, {
|
||||
checks: {
|
||||
submissions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
eventsCheckedTotal: 0,
|
||||
checksRunTotal: 0,
|
||||
checksTriggeredTotal: 0,
|
||||
rulesRunTotal: 0,
|
||||
rulesCachedTotal: 0,
|
||||
rulesTriggeredTotal: 0,
|
||||
actionsRunTotal: 0,
|
||||
maxWorkers: 0,
|
||||
subMaxWorkers: 0,
|
||||
globalMaxWorkers: 0,
|
||||
runningActivities: 0,
|
||||
queuedActivities: 0,
|
||||
});
|
||||
const {
|
||||
checks,
|
||||
maxWorkers,
|
||||
globalMaxWorkers,
|
||||
subMaxWorkers,
|
||||
runningActivities,
|
||||
queuedActivities,
|
||||
...rest
|
||||
} = totalStats;
|
||||
|
||||
let cumRaw = subManagerData.reduce((acc, curr) => {
|
||||
Object.keys(curr.stats.cache.types as ResourceStats).forEach((k) => {
|
||||
acc[k].requests += curr.stats.cache.types[k].requests;
|
||||
acc[k].miss += curr.stats.cache.types[k].miss;
|
||||
acc[k].identifierAverageHit += Number.parseFloat(curr.stats.cache.types[k].identifierAverageHit);
|
||||
acc[k].averageTimeBetweenHits += curr.stats.cache.types[k].averageTimeBetweenHits === 'N/A' ? 0 : Number.parseFloat(curr.stats.cache.types[k].averageTimeBetweenHits)
|
||||
});
|
||||
return acc;
|
||||
}, cacheStats());
|
||||
cumRaw = Object.keys(cumRaw).reduce((acc, curr) => {
|
||||
const per = acc[curr].miss === 0 ? 0 : formatNumber(acc[curr].miss / acc[curr].requests) * 100;
|
||||
// @ts-ignore
|
||||
acc[curr].missPercent = `${formatNumber(per, {toFixed: 0})}%`;
|
||||
acc[curr].identifierAverageHit = formatNumber(acc[curr].identifierAverageHit);
|
||||
acc[curr].averageTimeBetweenHits = formatNumber(acc[curr].averageTimeBetweenHits)
|
||||
return acc;
|
||||
}, cumRaw);
|
||||
const cacheReq = subManagerData.reduce((acc, curr) => acc + curr.stats.cache.totalRequests, 0);
|
||||
const cacheMiss = subManagerData.reduce((acc, curr) => acc + curr.stats.cache.totalMiss, 0);
|
||||
const aManagerWithDefaultResources = bot.subManagers.find(x => x.resources !== undefined && x.resources.cacheSettingsHash === 'default');
|
||||
let allManagerData: any = {
|
||||
name: 'All',
|
||||
linkName: 'All',
|
||||
indicator: 'green',
|
||||
maxWorkers,
|
||||
globalMaxWorkers,
|
||||
subMaxWorkers,
|
||||
runningActivities,
|
||||
queuedActivities,
|
||||
botState: {
|
||||
state: RUNNING,
|
||||
causedBy: SYSTEM
|
||||
},
|
||||
dryRun: boolToString(bot.dryRun === true),
|
||||
logs: logs.get('all'),
|
||||
checks: checks,
|
||||
softLimit: bot.softLimit,
|
||||
hardLimit: bot.hardLimit,
|
||||
stats: {
|
||||
...rest,
|
||||
cache: {
|
||||
currentKeyCount: aManagerWithDefaultResources !== undefined ? await aManagerWithDefaultResources.resources.getCacheKeyCount() : 'N/A',
|
||||
isShared: false,
|
||||
totalRequests: cacheReq,
|
||||
totalMiss: cacheMiss,
|
||||
missPercent: `${formatNumber(cacheMiss === 0 || cacheReq === 0 ? 0 :(cacheMiss/cacheReq) * 100, {toFixed: 0})}%`,
|
||||
types: {
|
||||
...cumRaw,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if (allManagerData.logs === undefined) {
|
||||
// this should happen but saw an edge case where potentially did
|
||||
logger.warn(`Logs for 'all' were undefined found but should always have a default empty value`);
|
||||
}
|
||||
// if(isOperator) {
|
||||
allManagerData.startedAt = bot.startedAt.local().format('MMMM D, YYYY h:mm A Z');
|
||||
allManagerData.heartbeatHuman = dayjs.duration({seconds: bot.heartbeatInterval}).humanize();
|
||||
allManagerData.heartbeat = bot.heartbeatInterval;
|
||||
allManagerData = {...allManagerData, ...opStats(bot)};
|
||||
//}
|
||||
|
||||
const botDur = dayjs.duration(dayjs().diff(bot.startedAt))
|
||||
if (allManagerData.stats.cache.totalRequests > 0) {
|
||||
const minutes = botDur.asMinutes();
|
||||
if (minutes < 10) {
|
||||
allManagerData.stats.cache.requestRate = formatNumber((10 / minutes) * allManagerData.stats.cache.totalRequests, {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
} else {
|
||||
allManagerData.stats.cache.requestRate = formatNumber(allManagerData.stats.cache.totalRequests / (minutes / 10), {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
allManagerData.stats.cache.requestRate = 0;
|
||||
}
|
||||
|
||||
const data = {
|
||||
userName: user,
|
||||
system: {
|
||||
startedAt: bot.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
...opStats(bot),
|
||||
},
|
||||
subreddits: [allManagerData, ...subManagerData],
|
||||
show: 'All',
|
||||
botName: bot.botName,
|
||||
botLink: bot.botLink,
|
||||
operatorDisplay: display,
|
||||
isOperator,
|
||||
operators: opNames.length === 0 ? 'None Specified' : name.join(', '),
|
||||
logSettings: {
|
||||
//limit: [10, 20, 50, 100, 200].map(x => `<a class="capitalize ${limit === x ? 'font-bold no-underline pointer-events-none' : ''}" data-limit="${x}" href="logs/settings/update?limit=${x}">${x}</a>`).join(' | '),
|
||||
limitSelect: [10, 20, 50, 100, 200].map(x => `<option ${limit === x ? 'selected' : ''} class="capitalize ${limit === x ? 'font-bold' : ''}" data-value="${x}">${x}</option>`).join(' | '),
|
||||
//sort: ['ascending', 'descending'].map(x => `<a class="capitalize ${sort === x ? 'font-bold no-underline pointer-events-none' : ''}" data-sort="${x}" href="logs/settings/update?sort=${x}">${x}</a>`).join(' | '),
|
||||
sortSelect: ['ascending', 'descending'].map(x => `<option ${sort === x ? 'selected' : ''} class="capitalize ${sort === x ? 'font-bold' : ''}" data-value="${x}">${x}</option>`).join(' '),
|
||||
//level: availableLevels.map(x => `<a class="capitalize log-${x} ${level === x ? `font-bold no-underline pointer-events-none` : ''}" data-log="${x}" href="logs/settings/update?level=${x}">${x}</a>`).join(' | '),
|
||||
levelSelect: availableLevels.map(x => `<option ${level === x ? 'selected' : ''} class="capitalize log-${x} ${level === x ? `font-bold` : ''}" data-value="${x}">${x}</option>`).join(' '),
|
||||
},
|
||||
};
|
||||
if (req.query.sub !== undefined) {
|
||||
const encoded = encodeURI(req.query.sub as string).toLowerCase();
|
||||
const shouldShow = data.subreddits.find(x => x.name.toLowerCase() === encoded);
|
||||
if (shouldShow !== undefined) {
|
||||
data.show = shouldShow.name;
|
||||
}
|
||||
}
|
||||
|
||||
res.render('status', data);
|
||||
});
|
||||
|
||||
app.getAsync('/logs/settings/update', async function (req, res) {
|
||||
const e = req.query;
|
||||
for (const [setting, val] of Object.entries(req.query)) {
|
||||
switch (setting) {
|
||||
case 'limit':
|
||||
req.session.limit = Number.parseInt(val as string);
|
||||
break;
|
||||
case 'sort':
|
||||
req.session.sort = val as string;
|
||||
break;
|
||||
case 'level':
|
||||
req.session.level = val as string;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const {limit = 200, level = 'verbose', sort = 'descending', user} = req.session;
|
||||
|
||||
res.send('OK');
|
||||
|
||||
const subMap = filterLogBySubreddit(subLogMap, req.session.subreddits, {
|
||||
level,
|
||||
operator: opNames.includes((user as string).toLowerCase()),
|
||||
user,
|
||||
limit,
|
||||
sort: (sort as 'descending' | 'ascending'),
|
||||
});
|
||||
const subArr: any = [];
|
||||
subMap.forEach((v: string[], k: string) => {
|
||||
subArr.push({name: k, logs: v.join('')});
|
||||
});
|
||||
io.emit('logClear', subArr);
|
||||
});
|
||||
|
||||
app.use('/config', [redditUserMiddleware]);
|
||||
app.getAsync('/config', async (req, res) => {
|
||||
const {subreddit} = req.query as any;
|
||||
if(!(req.session.subreddits as string[]).includes(subreddit)) {
|
||||
return res.render('error', {error: 'Cannot retrieve config for subreddit you do not manage or is not run by the bot', operatorDisplay: display});
|
||||
}
|
||||
const manager = bot.subManagers.find(x => x.displayLabel === subreddit);
|
||||
if (manager === undefined) {
|
||||
return res.render('error', {error: 'Cannot retrieve config for subreddit you do not manage or is not run by the bot', operatorDisplay: display});
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const wiki = await manager.subreddit.getWikiPage(manager.wikiLocation).fetch();
|
||||
const [obj, jsonErr, yamlErr] = parseFromJsonOrYamlToObject(wiki.content_md);
|
||||
res.render('config', {
|
||||
config: prettyPrintJson.toHtml(obj, {quoteKeys: true, indent: 2}),
|
||||
botName: bot.botName,
|
||||
botLink: bot.botLink,
|
||||
operatorDisplay: display,
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/action', [redditUserMiddleware, booleanMiddle(['force'])]);
|
||||
app.getAsync('/action', async (req, res) => {
|
||||
const {type, action, subreddit, force = false} = req.query as any;
|
||||
let subreddits: string[] = [];
|
||||
if (subreddit === 'All') {
|
||||
subreddits = req.session.subreddits as string[];
|
||||
} else if ((req.session.subreddits as string[]).includes(subreddit)) {
|
||||
subreddits = [subreddit];
|
||||
}
|
||||
|
||||
for (const s of subreddits) {
|
||||
const manager = bot.subManagers.find(x => x.displayLabel === s);
|
||||
if (manager === undefined) {
|
||||
logger.warn(`Manager for ${s} does not exist`, {subreddit: `/u/${req.session.user}`});
|
||||
continue;
|
||||
}
|
||||
const mLogger = manager.logger;
|
||||
mLogger.info(`/u/${req.session.user} invoked '${action}' action for ${type} on ${manager.displayLabel}`);
|
||||
try {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (type === 'bot') {
|
||||
await manager.start('user');
|
||||
} else if (type === 'queue') {
|
||||
manager.startQueue('user');
|
||||
} else {
|
||||
await manager.startEvents('user');
|
||||
}
|
||||
break;
|
||||
case 'stop':
|
||||
if (type === 'bot') {
|
||||
await manager.stop('user');
|
||||
} else if (type === 'queue') {
|
||||
await manager.stopQueue('user');
|
||||
} else {
|
||||
manager.stopEvents('user');
|
||||
}
|
||||
break;
|
||||
case 'pause':
|
||||
if (type === 'queue') {
|
||||
await manager.pauseQueue('user');
|
||||
} else {
|
||||
manager.pauseEvents('user');
|
||||
}
|
||||
break;
|
||||
case 'reload':
|
||||
const prevQueueState = manager.queueState.state;
|
||||
const newConfig = await manager.parseConfiguration('user', force);
|
||||
if (newConfig === false) {
|
||||
mLogger.info('Config was up-to-date');
|
||||
}
|
||||
if (newConfig && prevQueueState === RUNNING) {
|
||||
await manager.startQueue(USER);
|
||||
}
|
||||
break;
|
||||
case 'check':
|
||||
if (type === 'unmoderated') {
|
||||
const activities = await manager.subreddit.getUnmoderated({limit: 100});
|
||||
for (const a of activities.reverse()) {
|
||||
manager.queue.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const activities = await manager.subreddit.getModqueue({limit: 100});
|
||||
for (const a of activities.reverse()) {
|
||||
manager.queue.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof LoggedError)) {
|
||||
mLogger.error(err, {subreddit: manager.displayLabel});
|
||||
}
|
||||
}
|
||||
}
|
||||
res.send('OK');
|
||||
});
|
||||
|
||||
app.use('/check', [redditUserMiddleware, booleanMiddle(['dryRun'])]);
|
||||
app.getAsync('/check', async (req, res) => {
|
||||
const {url, dryRun, subreddit} = req.query as any;
|
||||
|
||||
let a;
|
||||
const commentId = commentReg(url);
|
||||
if (commentId !== undefined) {
|
||||
// @ts-ignore
|
||||
a = await bot.client.getComment(commentId);
|
||||
}
|
||||
if (a === undefined) {
|
||||
const submissionId = submissionReg(url);
|
||||
if (submissionId !== undefined) {
|
||||
// @ts-ignore
|
||||
a = await bot.client.getSubmission(submissionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (a === undefined) {
|
||||
logger.error('Could not parse Comment or Submission ID from given URL', {subreddit: `/u/${req.session.user}`});
|
||||
return res.send('OK');
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const activity = await a.fetch();
|
||||
const sub = await activity.subreddit.display_name;
|
||||
|
||||
let manager = subreddit === 'All' ? bot.subManagers.find(x => x.subreddit.display_name === sub) : bot.subManagers.find(x => x.displayLabel === subreddit);
|
||||
|
||||
if (manager === undefined || !(req.session.subreddits as string[]).includes(manager.displayLabel)) {
|
||||
let msg = 'Activity does not belong to a subreddit you moderate or the bot runs on.';
|
||||
if (subreddit === 'All') {
|
||||
msg = `${msg} If you want to test an Activity against a Subreddit\'s config it does not belong to then switch to that Subreddit's tab first.`
|
||||
}
|
||||
logger.error(msg, {subreddit: `/u/${req.session.user}`});
|
||||
return res.send('OK');
|
||||
}
|
||||
|
||||
// will run dryrun if specified or if running activity on subreddit it does not belong to
|
||||
const dr: boolean | undefined = (dryRun || manager.subreddit.display_name !== sub) ? true : undefined;
|
||||
manager.logger.info(`/u/${req.session.user} running${dr === true ? ' DRY RUN ' : ' '}check on${manager.subreddit.display_name !== sub ? ' FOREIGN ACTIVITY ' : ' '}${url}`);
|
||||
await manager.runChecks(activity instanceof Submission ? 'Submission' : 'Comment', activity, {dryRun: dr})
|
||||
}
|
||||
res.send('OK');
|
||||
})
|
||||
|
||||
setInterval(() => {
|
||||
// refresh op stats every 30 seconds
|
||||
io.emit('opStats', opStats(bot));
|
||||
// if (operatorSessionId !== undefined) {
|
||||
// io.to(operatorSessionId).emit('opStats', opStats(bot));
|
||||
// }
|
||||
}, 30000);
|
||||
|
||||
emitter.on('log', (log) => {
|
||||
const emittedSessions = [];
|
||||
const subName = parseSubredditLogName(log);
|
||||
if (subName !== undefined) {
|
||||
for (const [id, info] of connectedUsers) {
|
||||
const {subreddits, level = 'verbose', user} = info;
|
||||
if (isLogLineMinLevel(log, level) && (subreddits.includes(subName) || subName.includes(user))) {
|
||||
emittedSessions.push(id);
|
||||
io.to(id).emit('log', formatLogLineToHtml(log));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (operatorSessionIds.length > 0) {
|
||||
for(const id of operatorSessionIds) {
|
||||
io.to(id).emit('opStats', opStats(bot));
|
||||
if (subName === undefined || !emittedSessions.includes(id)) {
|
||||
const {level = 'verbose'} = connectedUsers.get(id) || {};
|
||||
if (isLogLineMinLevel(log, level)) {
|
||||
io.to(id).emit('log', formatLogLineToHtml(log));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await bot.runManagers();
|
||||
}
|
||||
|
||||
return [serverFunc, bot];
|
||||
};
|
||||
|
||||
const opStats = (bot: App) => {
|
||||
const limitReset = dayjs(bot.client.ratelimitExpiration);
|
||||
const nextHeartbeat = bot.nextHeartbeat !== undefined ? bot.nextHeartbeat.local().format('MMMM D, YYYY h:mm A Z') : 'N/A';
|
||||
const nextHeartbeatHuman = bot.nextHeartbeat !== undefined ? `in ${dayjs.duration(bot.nextHeartbeat.diff(dayjs())).humanize()}` : 'N/A'
|
||||
return {
|
||||
startedAtHuman: `${dayjs.duration(dayjs().diff(bot.startedAt)).humanize()}`,
|
||||
nextHeartbeat,
|
||||
nextHeartbeatHuman,
|
||||
apiLimit: bot.client.ratelimitRemaining,
|
||||
apiAvg: formatNumber(bot.apiRollingAvg),
|
||||
nannyMode: bot.nannyMode || 'Off',
|
||||
apiDepletion: bot.apiEstDepletion === undefined ? 'Not Calculated' : bot.apiEstDepletion.humanize(),
|
||||
limitReset,
|
||||
limitResetHuman: `in ${dayjs.duration(limitReset.diff(dayjs())).humanize()}`,
|
||||
}
|
||||
}
|
||||
|
||||
export default rcbServer;
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css"
|
||||
integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css"
|
||||
integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg=="
|
||||
crossorigin="anonymous"/>
|
||||
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
|
||||
<link rel="stylesheet" href="public/themeToggle.css">
|
||||
<link rel="stylesheet" href="public/app.css">
|
||||
<link rel="stylesheet" href="public/json.css">
|
||||
<title>CM for <%= botName %></title>
|
||||
<!--<title><%# `CM for /u/${botName}`%></title>-->
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<!--icons from https://heroicons.com -->
|
||||
</head>
|
||||
<body style="user-select: none;" class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/authTitle') %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-700 dark:text-white">
|
||||
<div class="p-6 md:px-10 md:py-6 space-y-3">
|
||||
<div>Note: Comments have been removed</div>
|
||||
<pre style="user-select: text;"><%- config %></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (e.target.id === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelector("#themeToggle").checked = localStorage.getItem('ms-dark') !== 'no';
|
||||
document.querySelector("#themeToggle").onchange = (e) => {
|
||||
if (e.target.checked === true) {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,60 +0,0 @@
|
||||
<html>
|
||||
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/title', {title: ' OAuth Helper'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Hi! Looks like you're setting up your bot. To get running:</div>
|
||||
<div class="text-lg text-semibold my-3">1. Set your redirect URL</div>
|
||||
<input id="redirectUri" style="min-width:500px;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2" value="<%= redirectUri %>">
|
||||
<div class="my-2">
|
||||
<input type="checkbox" id="wikiedit" name="wikiedit"
|
||||
checked>
|
||||
<label for="wikiedit">Include <span class="font-mono">wikiedit</span> permission for Toolbox
|
||||
User Notes</label>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div>This is the URL Reddit will redirect you to once you have authorized an account to be used
|
||||
with your application.
|
||||
</div>
|
||||
<div>The input field has been pre-filled with either:
|
||||
<ul class="list-inside list-disc">
|
||||
<li>What you provided to the program as an argument/environmental variable or</li>
|
||||
<li>The current URL in your browser that would be used -- if you are using a reverse
|
||||
proxy this may be different so double check
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>Make sure it matches what is found in the <b>redirect uri</b> for your <a target="_blank"
|
||||
href="https://www.reddit.com/prefs/apps">application
|
||||
on Reddit</a> and <b>it must end with "callback"</b></div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">2. Login to Reddit with the account that will be the bot
|
||||
</div>
|
||||
Protip: Login to Reddit in an Incognito session, then open this URL in a new tab.
|
||||
<div class="text-lg text-semibold my-3">3. <a id="doAuth" href="">Authorize your bot account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
if (document.querySelector('#redirectUri').value === '') {
|
||||
document.querySelector('#redirectUri').value = `${document.location.href}callback`;
|
||||
}
|
||||
|
||||
document.querySelector('#doAuth').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const wikiEdit = document.querySelector('#wikiedit').checked ? 1 : 0;
|
||||
const url = `${document.location.href}auth?redirect=${document.querySelector('#redirectUri').value}&wikiEdit=${wikiEdit}`;
|
||||
window.location.href = url;
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,13 +0,0 @@
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
<div class="px-4 width-full relative">
|
||||
<div><a href="https://github.com/FoxxMD/context-mod">ContextMod</a> <%= title %></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center flex-end text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,12 +16,13 @@ import {
|
||||
DEFAULT_POLLING_INTERVAL,
|
||||
DEFAULT_POLLING_LIMIT, Invokee,
|
||||
ManagerOptions, ManagerStateChangeOption, PAUSED,
|
||||
PollingOptionsStrong, RUNNING, RunState, STOPPED, SYSTEM, USER
|
||||
PollingOptionsStrong, ResourceStats, RUNNING, RunState, STOPPED, SYSTEM, USER
|
||||
} from "../Common/interfaces";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {activityIsRemoved, itemContentPeek} from "../Utils/SnoowrapUtils";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import ResourceManager, {
|
||||
import {
|
||||
BotResourcesManager,
|
||||
SubredditResourceConfig,
|
||||
SubredditResources,
|
||||
SubredditResourceSetOptions
|
||||
@@ -60,6 +61,39 @@ export interface RuntimeManagerOptions extends ManagerOptions {
|
||||
maxWorkers: number;
|
||||
}
|
||||
|
||||
export interface ManagerStats {
|
||||
eventsCheckedTotal: number
|
||||
eventsCheckedSinceStartTotal: number
|
||||
eventsAvg: number
|
||||
checksRunTotal: number
|
||||
checksRunSinceStartTotal: number
|
||||
checksTriggered: number
|
||||
checksTriggeredTotal: number
|
||||
checksTriggeredSinceStart: number
|
||||
checksTriggeredSinceStartTotal: number
|
||||
rulesRunTotal: number
|
||||
rulesRunSinceStartTotal: number
|
||||
rulesCachedTotal: number
|
||||
rulesCachedSinceStartTotal: number
|
||||
rulesTriggeredTotal: number
|
||||
rulesTriggeredSinceStartTotal: number
|
||||
rulesAvg: number
|
||||
actionsRun: number
|
||||
actionsRunTotal: number
|
||||
actionsRunSinceStart: number,
|
||||
actionsRunSinceStartTotal: number
|
||||
cache: {
|
||||
provider: string,
|
||||
currentKeyCount: number,
|
||||
isShared: boolean,
|
||||
totalRequests: number,
|
||||
totalMiss: number,
|
||||
missPercent: string,
|
||||
requestRate: number,
|
||||
types: ResourceStats
|
||||
},
|
||||
}
|
||||
|
||||
export class Manager {
|
||||
subreddit: Subreddit;
|
||||
client: Snoowrap;
|
||||
@@ -79,6 +113,7 @@ export class Manager {
|
||||
modStreamCallbacks: Map<string, any> = new Map();
|
||||
dryRun?: boolean;
|
||||
sharedModqueue: boolean;
|
||||
cacheManager: BotResourcesManager;
|
||||
globalDryRun?: boolean;
|
||||
emitter: EventEmitter = new EventEmitter();
|
||||
queue: QueueObject<CheckTask>;
|
||||
@@ -131,7 +166,7 @@ export class Manager {
|
||||
actionsRun: Map<string, number> = new Map();
|
||||
actionsRunSinceStart: Map<string, number> = new Map();
|
||||
|
||||
getStats = async () => {
|
||||
getStats = async (): Promise<ManagerStats> => {
|
||||
const data: any = {
|
||||
eventsCheckedTotal: this.eventsCheckedTotal,
|
||||
eventsCheckedSinceStartTotal: this.eventsCheckedSinceStartTotal,
|
||||
@@ -184,7 +219,7 @@ export class Manager {
|
||||
return this.displayLabel;
|
||||
}
|
||||
|
||||
constructor(sub: Subreddit, client: Snoowrap, logger: Logger, opts: RuntimeManagerOptions = {botName: 'ContextMod', maxWorkers: 1}) {
|
||||
constructor(sub: Subreddit, client: Snoowrap, logger: Logger, cacheManager: BotResourcesManager, opts: RuntimeManagerOptions = {botName: 'ContextMod', maxWorkers: 1}) {
|
||||
const {dryRun, sharedModqueue = false, wikiLocation = 'botconfig/contextbot', botName, maxWorkers} = opts;
|
||||
this.displayLabel = opts.nickname || `${sub.display_name_prefixed}`;
|
||||
const getLabels = this.getCurrentLabels;
|
||||
@@ -207,6 +242,7 @@ export class Manager {
|
||||
this.botName = botName;
|
||||
this.globalMaxWorkers = maxWorkers;
|
||||
this.notificationManager = new NotificationManager(this.logger, this.subreddit, this.displayLabel, botName);
|
||||
this.cacheManager = cacheManager;
|
||||
|
||||
this.queue = this.generateQueue(this.getMaxWorkers(this.globalMaxWorkers));
|
||||
this.queue.pause();
|
||||
@@ -342,9 +378,10 @@ export class Manager {
|
||||
footer,
|
||||
logger: this.logger,
|
||||
subreddit: this.subreddit,
|
||||
caching
|
||||
caching,
|
||||
client: this.client,
|
||||
};
|
||||
this.resources = ResourceManager.set(this.subreddit.display_name, resourceConfig);
|
||||
this.resources = this.cacheManager.set(this.subreddit.display_name, resourceConfig);
|
||||
this.resources.setLogger(this.logger);
|
||||
|
||||
this.logger.info('Subreddit-specific options updated');
|
||||
@@ -358,7 +395,9 @@ export class Manager {
|
||||
...jCheck,
|
||||
dryRun: this.dryRun || jCheck.dryRun,
|
||||
logger: this.logger,
|
||||
subredditName: this.subreddit.display_name
|
||||
subredditName: this.subreddit.display_name,
|
||||
resources: this.resources,
|
||||
client: this.client,
|
||||
};
|
||||
if (jCheck.kind === 'comment') {
|
||||
commentChecks.push(new CommentCheck(checkConfig));
|
||||
@@ -604,7 +643,7 @@ export class Manager {
|
||||
if (limit === DEFAULT_POLLING_LIMIT && interval === DEFAULT_POLLING_INTERVAL && this.sharedModqueue) {
|
||||
modStreamType = 'unmoderated';
|
||||
// use default mod stream from resources
|
||||
stream = ResourceManager.modStreams.get('unmoderated') as SPoll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
stream = this.cacheManager.modStreams.get('unmoderated') as SPoll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
} else {
|
||||
stream = new UnmoderatedStream(this.client, {
|
||||
subreddit: this.subreddit.display_name,
|
||||
@@ -617,7 +656,7 @@ export class Manager {
|
||||
if (limit === DEFAULT_POLLING_LIMIT && interval === DEFAULT_POLLING_INTERVAL) {
|
||||
modStreamType = 'modqueue';
|
||||
// use default mod stream from resources
|
||||
stream = ResourceManager.modStreams.get('modqueue') as SPoll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
stream = this.cacheManager.modStreams.get('modqueue') as SPoll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
} else {
|
||||
stream = new ModQueueStream(this.client, {
|
||||
subreddit: this.subreddit.display_name,
|
||||
@@ -697,29 +736,6 @@ export class Manager {
|
||||
}
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
if (this.submissionChecks.length === 0 && this.commentChecks.length === 0) {
|
||||
this.logger.warn('No submission or comment checks to run! Bot will not run.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const s of this.streams) {
|
||||
s.startInterval();
|
||||
}
|
||||
this.startedAt = dayjs();
|
||||
this.running = true;
|
||||
this.manuallyStopped = false;
|
||||
this.logger.info('Bot Running');
|
||||
|
||||
await pEvent(this.emitter, 'end');
|
||||
} catch (err) {
|
||||
this.logger.error('Too many request errors occurred or an unhandled error was encountered, manager is stopping');
|
||||
} finally {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
|
||||
startQueue(causedBy: Invokee = 'system', options?: ManagerStateChangeOption) {
|
||||
const {reason, suppressNotification = false} = options || {};
|
||||
if(this.queueState.state === RUNNING) {
|
||||
@@ -876,7 +892,7 @@ export class Manager {
|
||||
}
|
||||
this.streams = [];
|
||||
for (const [k, v] of this.modStreamCallbacks) {
|
||||
const stream = ResourceManager.modStreams.get(k) as Poll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
const stream = this.cacheManager.modStreams.get(k) as Poll<Snoowrap.Submission | Snoowrap.Comment>;
|
||||
stream.removeListener('item', v);
|
||||
}
|
||||
this.startedAt = undefined;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
activityIsRemoved,
|
||||
AuthorActivitiesOptions,
|
||||
AuthorTypedActivitiesOptions, BOT_LINK,
|
||||
getAuthorActivities, singleton,
|
||||
getAuthorActivities,
|
||||
testAuthorCriteria
|
||||
} from "../Utils/SnoowrapUtils";
|
||||
import Subreddit from 'snoowrap/dist/objects/Subreddit';
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from "../util";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {
|
||||
BotInstanceConfig,
|
||||
CacheOptions, CommentState,
|
||||
Footer, OperatorConfig, ResourceStats, SubmissionState,
|
||||
SubredditCacheConfig, TTLConfig, TypedActivityStates
|
||||
@@ -40,6 +41,7 @@ export interface SubredditResourceConfig extends Footer {
|
||||
caching?: SubredditCacheConfig,
|
||||
subreddit: Subreddit,
|
||||
logger: Logger;
|
||||
client: Snoowrap
|
||||
}
|
||||
|
||||
interface SubredditResourceOptions extends Footer {
|
||||
@@ -49,6 +51,7 @@ interface SubredditResourceOptions extends Footer {
|
||||
cacheSettingsHash: string
|
||||
subreddit: Subreddit,
|
||||
logger: Logger;
|
||||
client: Snoowrap;
|
||||
}
|
||||
|
||||
export interface SubredditResourceSetOptions extends SubredditCacheConfig, Footer {
|
||||
@@ -67,6 +70,7 @@ export class SubredditResources {
|
||||
userNotes: UserNotes;
|
||||
footer: false | string = DEFAULT_FOOTER;
|
||||
subreddit: Subreddit
|
||||
client: Snoowrap
|
||||
cache: Cache
|
||||
cacheType: string
|
||||
cacheSettingsHash?: string;
|
||||
@@ -87,10 +91,12 @@ export class SubredditResources {
|
||||
cache,
|
||||
cacheType,
|
||||
cacheSettingsHash,
|
||||
client,
|
||||
} = options || {};
|
||||
|
||||
this.cacheSettingsHash = cacheSettingsHash;
|
||||
this.cache = cache;
|
||||
this.client = client;
|
||||
this.cacheType = cacheType;
|
||||
this.authorTTL = authorTTL;
|
||||
this.wikiTTL = wikiTTL;
|
||||
@@ -98,7 +104,7 @@ export class SubredditResources {
|
||||
this.subreddit = subreddit;
|
||||
this.name = name;
|
||||
if (logger === undefined) {
|
||||
const alogger = winston.loggers.get('default')
|
||||
const alogger = winston.loggers.get('app')
|
||||
this.logger = alogger.child({labels: [this.name, 'Resource Cache']}, mergeArr);
|
||||
} else {
|
||||
this.logger = logger.child({labels: ['Resource Cache']}, mergeArr);
|
||||
@@ -314,9 +320,7 @@ export class SubredditResources {
|
||||
if (wikiContext.subreddit === undefined || wikiContext.subreddit.toLowerCase() === subreddit.display_name) {
|
||||
sub = subreddit;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const client = singleton.getClient();
|
||||
sub = client.getSubreddit(wikiContext.subreddit);
|
||||
sub = this.client.getSubreddit(wikiContext.subreddit);
|
||||
}
|
||||
try {
|
||||
// @ts-ignore
|
||||
@@ -381,10 +385,8 @@ export class SubredditResources {
|
||||
let states = s;
|
||||
// optimize for submission only checks on comment item
|
||||
if (item instanceof Comment && states.length === 1 && Object.keys(states[0]).length === 1 && (states[0] as CommentState).submissionState !== undefined) {
|
||||
// get submission
|
||||
const client = singleton.getClient();
|
||||
// @ts-ignore
|
||||
const subProxy = await client.getSubmission(await i.link_id);
|
||||
const subProxy = await this.client.getSubmission(await i.link_id);
|
||||
// @ts-ignore
|
||||
item = await this.getActivity(subProxy);
|
||||
states = (states[0] as CommentState).submissionState as SubmissionState[];
|
||||
@@ -420,7 +422,7 @@ export class SubredditResources {
|
||||
return true;
|
||||
}
|
||||
|
||||
const log = logger.child({leaf: 'Item Check'});
|
||||
const log = logger.child({leaf: 'Item Check'}, mergeArr);
|
||||
|
||||
for (const crit of stateCriteria) {
|
||||
const pass = await (async () => {
|
||||
@@ -434,9 +436,8 @@ export class SubredditResources {
|
||||
continue;
|
||||
}
|
||||
// get submission
|
||||
const client = singleton.getClient();
|
||||
// @ts-ignore
|
||||
const subProxy = await client.getSubmission(await item.link_id);
|
||||
const subProxy = await this.client.getSubmission(await item.link_id);
|
||||
// @ts-ignore
|
||||
const sub = await this.getActivity(subProxy);
|
||||
// @ts-ignore
|
||||
@@ -559,18 +560,18 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
|
||||
class SubredditResourcesManager {
|
||||
export class BotResourcesManager {
|
||||
resources: Map<string, SubredditResources> = new Map();
|
||||
authorTTL: number = 10000;
|
||||
enabled: boolean = true;
|
||||
modStreams: Map<string, SPoll<Snoowrap.Submission | Snoowrap.Comment>> = new Map();
|
||||
defaultCache!: Cache;
|
||||
defaultCache: Cache;
|
||||
cacheType: string = 'none';
|
||||
cacheHash!: string;
|
||||
ttlDefaults!: Required<TTLConfig>;
|
||||
cacheHash: string;
|
||||
ttlDefaults: Required<TTLConfig>;
|
||||
pruneInterval: any;
|
||||
|
||||
setDefaultsFromConfig(config: OperatorConfig) {
|
||||
constructor(config: BotInstanceConfig) {
|
||||
const {
|
||||
caching: {
|
||||
authorTTL,
|
||||
@@ -584,33 +585,27 @@ class SubredditResourcesManager {
|
||||
caching,
|
||||
} = config;
|
||||
this.cacheHash = objectHash.sha1(caching);
|
||||
this.setTTLDefaults({authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL});
|
||||
this.setDefaultCache(provider);
|
||||
}
|
||||
this.ttlDefaults = {authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL};
|
||||
|
||||
setDefaultCache(options: CacheOptions) {
|
||||
const options = provider;
|
||||
this.cacheType = options.store;
|
||||
this.defaultCache = createCacheManager(options);
|
||||
if(this.cacheType === 'memory') {
|
||||
if (this.cacheType === 'memory') {
|
||||
const min = Math.min(...([this.ttlDefaults.wikiTTL, this.ttlDefaults.authorTTL, this.ttlDefaults.userNotesTTL].filter(x => x !== 0)));
|
||||
if(min > 0) {
|
||||
if (min > 0) {
|
||||
// set default prune interval
|
||||
this.pruneInterval = setInterval(() => {
|
||||
// @ts-ignore
|
||||
this.defaultCache?.store.prune();
|
||||
// kinda hacky but whatever
|
||||
const logger = winston.loggers.get('default');
|
||||
const logger = winston.loggers.get('app');
|
||||
logger.debug('Pruned Shared Cache');
|
||||
// prune interval should be twice the smallest TTL
|
||||
},min * 1000 * 2)
|
||||
}, min * 1000 * 2)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTTLDefaults(def: Required<TTLConfig>) {
|
||||
this.ttlDefaults = def;
|
||||
}
|
||||
|
||||
get(subName: string): SubredditResources | undefined {
|
||||
if (this.resources.has(subName)) {
|
||||
return this.resources.get(subName) as SubredditResources;
|
||||
@@ -674,7 +669,3 @@ class SubredditResourcesManager {
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
|
||||
const manager = new SubredditResourcesManager();
|
||||
|
||||
export default manager;
|
||||
|
||||
25
src/Utils/AbortToken.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
//https://gist.github.com/pygy/6290f78b078e22418821b07d8d63f111#gistcomment-3408351
|
||||
class AbortToken {
|
||||
private readonly abortSymbol = Symbol('cancelled');
|
||||
private abortPromise: Promise<any>;
|
||||
private resolve!: Function; // Works due to promise init
|
||||
|
||||
constructor() {
|
||||
this.abortPromise = new Promise(res => this.resolve = res);
|
||||
}
|
||||
|
||||
public async wrap<T>(p: PromiseLike<T>): Promise<T> {
|
||||
const result = await Promise.race([p, this.abortPromise]);
|
||||
if (result === this.abortSymbol) {
|
||||
throw new Error('aborted');
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public abort() {
|
||||
this.resolve(this.abortSymbol);
|
||||
}
|
||||
}
|
||||
|
||||
export default AbortToken;
|
||||
@@ -636,24 +636,3 @@ export const activityIsDeleted = (item: Submission | Comment): boolean => {
|
||||
}
|
||||
return item.author.name === '[deleted]'
|
||||
}
|
||||
|
||||
class ClientSingleton {
|
||||
client!: Snoowrap;
|
||||
|
||||
constructor(client?: Snoowrap) {
|
||||
if (client !== undefined) {
|
||||
this.client = client;
|
||||
}
|
||||
}
|
||||
|
||||
setClient(client: Snoowrap) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
getClient(): Snoowrap {
|
||||
return this.client;
|
||||
}
|
||||
}
|
||||
|
||||
// quick little hack to get access to the client without having to pass it all the way down the chain
|
||||
export const singleton = new ClientSingleton();
|
||||
|
||||
@@ -1,29 +1,42 @@
|
||||
import {labelledFormat, logLevels} from "../util";
|
||||
import winston, {Logger} from "winston";
|
||||
import {DuplexTransport} from "winston-duplex";
|
||||
|
||||
const {transports} = winston;
|
||||
|
||||
export const getLogger = (options: any, name = 'default'): Logger => {
|
||||
export const getLogger = (options: any, name = 'app'): Logger => {
|
||||
if(!winston.loggers.has(name)) {
|
||||
const {
|
||||
path,
|
||||
level,
|
||||
additionalTransports = [],
|
||||
defaultLabel = 'App',
|
||||
} = options || {};
|
||||
|
||||
const consoleTransport = new transports.Console();
|
||||
const consoleTransport = new transports.Console({
|
||||
handleExceptions: true,
|
||||
// @ts-expect-error
|
||||
handleRejections: true,
|
||||
});
|
||||
|
||||
const myTransports = [
|
||||
consoleTransport,
|
||||
new DuplexTransport({
|
||||
stream: {
|
||||
transform(chunk,e, cb) {
|
||||
cb(null, chunk);
|
||||
},
|
||||
objectMode: true,
|
||||
},
|
||||
name: 'duplex',
|
||||
dump: false,
|
||||
handleExceptions: true,
|
||||
// @ts-expect-error
|
||||
handleRejections: true,
|
||||
}),
|
||||
...additionalTransports,
|
||||
];
|
||||
|
||||
let errorTransports = [consoleTransport];
|
||||
|
||||
for (const a of additionalTransports) {
|
||||
myTransports.push(a);
|
||||
errorTransports.push(a);
|
||||
}
|
||||
|
||||
if (path !== undefined && path !== '') {
|
||||
const rotateTransport = new winston.transports.DailyRotateFile({
|
||||
dirname: path,
|
||||
@@ -31,21 +44,19 @@ export const getLogger = (options: any, name = 'default'): Logger => {
|
||||
symlinkName: 'contextBot-current.log',
|
||||
filename: 'contextBot-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
maxSize: '5m'
|
||||
maxSize: '5m',
|
||||
handleExceptions: true,
|
||||
handleRejections: true,
|
||||
});
|
||||
// @ts-ignore
|
||||
myTransports.push(rotateTransport);
|
||||
// @ts-ignore
|
||||
errorTransports.push(rotateTransport);
|
||||
}
|
||||
|
||||
const loggerOptions = {
|
||||
level: level || 'info',
|
||||
format: labelledFormat(),
|
||||
format: labelledFormat(defaultLabel),
|
||||
transports: myTransports,
|
||||
levels: logLevels,
|
||||
exceptionHandlers: errorTransports,
|
||||
rejectionHandlers: errorTransports,
|
||||
};
|
||||
|
||||
winston.loggers.add(name, loggerOptions);
|
||||
|
||||
1036
src/Web/Client/index.ts
Normal file
108
src/Web/Common/defaults.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import {BotStats, BotStatusResponse, SubredditDataResponse} from "./interfaces";
|
||||
import {ManagerStats, RunningState} from "../../Subreddit/Manager";
|
||||
import {Invokee, RunState} from "../../Common/interfaces";
|
||||
import {cacheStats} from "../../util";
|
||||
|
||||
const managerStats: ManagerStats = {
|
||||
actionsRun: 0,
|
||||
actionsRunSinceStart: 0,
|
||||
actionsRunSinceStartTotal: 0,
|
||||
actionsRunTotal: 0,
|
||||
cache: {
|
||||
currentKeyCount: 0,
|
||||
isShared: false,
|
||||
missPercent: "-",
|
||||
provider: "-",
|
||||
requestRate: 0,
|
||||
totalMiss: 0,
|
||||
totalRequests: 0,
|
||||
types: cacheStats()
|
||||
},
|
||||
checksRunSinceStartTotal: 0,
|
||||
checksRunTotal: 0,
|
||||
checksTriggered: 0,
|
||||
checksTriggeredSinceStart: 0,
|
||||
checksTriggeredSinceStartTotal: 0,
|
||||
checksTriggeredTotal: 0,
|
||||
eventsAvg: 0,
|
||||
eventsCheckedSinceStartTotal: 0,
|
||||
eventsCheckedTotal: 0,
|
||||
rulesAvg: 0,
|
||||
rulesCachedSinceStartTotal: 0,
|
||||
rulesCachedTotal: 0,
|
||||
rulesRunSinceStartTotal: 0,
|
||||
rulesRunTotal: 0,
|
||||
rulesTriggeredSinceStartTotal: 0,
|
||||
rulesTriggeredTotal: 0
|
||||
};
|
||||
const botStats: BotStats = {
|
||||
apiAvg: '-',
|
||||
apiDepletion: "-",
|
||||
apiLimit: 0,
|
||||
limitReset: '-',
|
||||
limitResetHuman: "-",
|
||||
nannyMode: "-",
|
||||
nextHeartbeat: "-",
|
||||
nextHeartbeatHuman: "-",
|
||||
startedAtHuman: "-"
|
||||
};
|
||||
|
||||
const runningState: RunningState = {
|
||||
causedBy: '-' as Invokee,
|
||||
state: '-' as RunState,
|
||||
}
|
||||
|
||||
const sub: SubredditDataResponse = {
|
||||
botState: runningState,
|
||||
checks: {comments: 0, submissions: 0},
|
||||
delayBy: "-",
|
||||
dryRun: false,
|
||||
eventsState: runningState,
|
||||
globalMaxWorkers: 0,
|
||||
hardLimit: 0,
|
||||
heartbeat: 0,
|
||||
heartbeatHuman: "-",
|
||||
indicator: "-",
|
||||
logs: [],
|
||||
maxWorkers: 0,
|
||||
name: "-",
|
||||
pollingInfo: [],
|
||||
queueState: runningState,
|
||||
queuedActivities: 0,
|
||||
runningActivities: 0,
|
||||
softLimit: 0,
|
||||
startedAt: "-",
|
||||
startedAtHuman: "-",
|
||||
stats: managerStats,
|
||||
subMaxWorkers: 0,
|
||||
validConfig: false,
|
||||
wikiHref: "-",
|
||||
wikiLastCheck: "-",
|
||||
wikiLastCheckHuman: "-",
|
||||
wikiLocation: "-",
|
||||
wikiRevision: "-",
|
||||
wikiRevisionHuman: "-"
|
||||
};
|
||||
|
||||
export const defaultBotStatus = (subreddits: string[] = []) => {
|
||||
|
||||
const subs: SubredditDataResponse[] = [
|
||||
{
|
||||
...sub,
|
||||
name: 'All',
|
||||
},
|
||||
...subreddits.map(x => ({...sub, name: x}))
|
||||
];
|
||||
|
||||
const data: BotStatusResponse = {
|
||||
subreddits: subs,
|
||||
system: {
|
||||
startedAt: '-',
|
||||
running: false,
|
||||
account: '-',
|
||||
name: '-',
|
||||
...botStats,
|
||||
}
|
||||
};
|
||||
return data;
|
||||
}
|
||||
59
src/Web/Common/interfaces.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import {ManagerStats, RunningState} from "../../Subreddit/Manager";
|
||||
|
||||
export interface BotStats {
|
||||
startedAtHuman: string,
|
||||
nextHeartbeat: string,
|
||||
nextHeartbeatHuman: string,
|
||||
apiLimit: number,
|
||||
apiAvg: string | number,
|
||||
nannyMode: string,
|
||||
apiDepletion: string,
|
||||
limitReset: string | number,
|
||||
limitResetHuman: string
|
||||
}
|
||||
|
||||
export interface SubredditDataResponse {
|
||||
name: string
|
||||
logs: string[]
|
||||
botState: RunningState
|
||||
eventsState: RunningState
|
||||
queueState: RunningState
|
||||
indicator: string
|
||||
queuedActivities: number
|
||||
runningActivities: number
|
||||
maxWorkers: number
|
||||
subMaxWorkers: number
|
||||
globalMaxWorkers: number
|
||||
validConfig: string | boolean
|
||||
dryRun: string | boolean
|
||||
pollingInfo: string[]
|
||||
checks: {
|
||||
submissions: number
|
||||
comments: number
|
||||
}
|
||||
wikiLocation: string
|
||||
wikiHref: string
|
||||
wikiRevisionHuman: string
|
||||
wikiRevision: string
|
||||
wikiLastCheckHuman: string
|
||||
wikiLastCheck: string
|
||||
stats: ManagerStats
|
||||
startedAt: string
|
||||
startedAtHuman: string
|
||||
delayBy: string
|
||||
softLimit?: number
|
||||
hardLimit?: number
|
||||
heartbeatHuman?: string
|
||||
heartbeat: number
|
||||
}
|
||||
|
||||
export interface BotStatusResponse {
|
||||
system: BotStats & {
|
||||
startedAt: string,
|
||||
name: string,
|
||||
running: boolean,
|
||||
error?: string,
|
||||
account: string,
|
||||
}
|
||||
subreddits: SubredditDataResponse[]
|
||||
}
|
||||
33
src/Web/Common/middleware.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Request, Response} from 'express';
|
||||
|
||||
export interface boolOptions {
|
||||
name: string,
|
||||
defaultVal: any
|
||||
}
|
||||
|
||||
export const booleanMiddle = (boolParams: (string | boolOptions)[] = []) => async (req: Request, res: Response, next: Function) => {
|
||||
for (const b of boolParams) {
|
||||
const opts = typeof b === 'string' ? {name: b, defaultVal: undefined} : b as boolOptions;
|
||||
|
||||
const bVal = req.query[opts.name] as any;
|
||||
if (bVal !== undefined) {
|
||||
let truthyVal: boolean;
|
||||
if (bVal === 'true' || bVal === true || bVal === 1 || bVal === '1') {
|
||||
truthyVal = true;
|
||||
} else if (bVal === 'false' || bVal === false || bVal === 0 || bVal === '0') {
|
||||
truthyVal = false;
|
||||
} else {
|
||||
res.status(400);
|
||||
return res.send(`Expected query parameter ${opts.name} to be a truthy value. Got "${bVal}" but must be one of these: true/false, 1/0`);
|
||||
}
|
||||
// @ts-ignore
|
||||
req.query[opts.name] = truthyVal;
|
||||
} else if (opts.defaultVal !== undefined) {
|
||||
req.query[opts.name] = opts.defaultVal;
|
||||
} else {
|
||||
res.status(400);
|
||||
return res.send(`Expected query parameter ${opts.name} to be a truthy value but it was missing. Must be one of these: true/false, 1/0`);
|
||||
}
|
||||
}
|
||||
next();
|
||||
}
|
||||
22
src/Web/Common/util.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {App} from "../../App";
|
||||
import {BotStats} from "./interfaces";
|
||||
import dayjs from "dayjs";
|
||||
import {formatNumber} from "../../util";
|
||||
import Bot from "../../Bot";
|
||||
|
||||
export const opStats = (bot: Bot): BotStats => {
|
||||
const limitReset = dayjs(bot.client.ratelimitExpiration);
|
||||
const nextHeartbeat = bot.nextHeartbeat !== undefined ? bot.nextHeartbeat.local().format('MMMM D, YYYY h:mm A Z') : 'N/A';
|
||||
const nextHeartbeatHuman = bot.nextHeartbeat !== undefined ? `in ${dayjs.duration(bot.nextHeartbeat.diff(dayjs())).humanize()}` : 'N/A'
|
||||
return {
|
||||
startedAtHuman: `${dayjs.duration(dayjs().diff(bot.startedAt)).humanize()}`,
|
||||
nextHeartbeat,
|
||||
nextHeartbeatHuman,
|
||||
apiLimit: bot.client !== undefined ? bot.client.ratelimitRemaining : 0,
|
||||
apiAvg: formatNumber(bot.apiRollingAvg),
|
||||
nannyMode: bot.nannyMode || 'Off',
|
||||
apiDepletion: bot.apiEstDepletion === undefined ? 'Not Calculated' : bot.apiEstDepletion.humanize(),
|
||||
limitReset: limitReset.format(),
|
||||
limitResetHuman: `in ${dayjs.duration(limitReset.diff(dayjs())).humanize()}`,
|
||||
}
|
||||
}
|
||||
27
src/Web/Server/interfaces.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Request } from "express";
|
||||
import {App} from "../../App";
|
||||
import Bot from "../../Bot";
|
||||
|
||||
// export interface ServerRequest extends Request {
|
||||
// botApp: App
|
||||
// bot?: Bot
|
||||
// //user?: AuthenticatedUser
|
||||
// }
|
||||
//
|
||||
// export interface ServerRequestRedditor extends ServerRequest {
|
||||
// user?: AuthenticatedRedditUser
|
||||
// }
|
||||
//
|
||||
// export interface AuthenticatedUser extends Express.User {
|
||||
// machine: boolean
|
||||
// }
|
||||
//
|
||||
// export interface AuthenticatedRedditUser extends AuthenticatedUser {
|
||||
// name: string
|
||||
// subreddits: string[]
|
||||
// isOperator: boolean
|
||||
// realManagers: string[]
|
||||
// moderatedManagers: string[]
|
||||
// realBots: string[]
|
||||
// moderatedBots: string[]
|
||||
// }
|
||||
33
src/Web/Server/middleware.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import {Request, Response} from "express";
|
||||
import Bot from "../../Bot";
|
||||
|
||||
export const authUserCheck = (userRequired: boolean = true) => async (req: Request, res: Response, next: Function) => {
|
||||
if (req.isAuthenticated()) {
|
||||
if (userRequired && req.user.machine) {
|
||||
return res.status(403).send('Must be authenticated as a user to access this route');
|
||||
}
|
||||
return next();
|
||||
} else {
|
||||
return res.status(401).send('Must be authenticated to access this route');
|
||||
}
|
||||
}
|
||||
|
||||
export const botRoute = (required = true) => async (req: Request, res: Response, next: Function) => {
|
||||
const {bot: botVal} = req.query;
|
||||
if (botVal === undefined) {
|
||||
if(required) {
|
||||
return res.status(400).send("Must specify 'bot' parameter");
|
||||
}
|
||||
return next();
|
||||
}
|
||||
const botStr = botVal as string;
|
||||
|
||||
if(req.user !== undefined) {
|
||||
if (req.user.realBots === undefined || !req.user.realBots.map(x => x.toLowerCase()).includes(botStr.toLowerCase())) {
|
||||
return res.status(404).send(`Bot named ${botStr} does not exist or you do not have permission to access it.`);
|
||||
}
|
||||
req.serverBot = req.botApp.bots.find(x => x.botName === botStr) as Bot;
|
||||
return next();
|
||||
}
|
||||
return next();
|
||||
}
|
||||
35
src/Web/Server/routes/authenticated/applicationRoutes.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {Router} from '@awaitjs/express';
|
||||
import {Request, Response} from 'express';
|
||||
import {authUserCheck} from "../../middleware";
|
||||
|
||||
const router = Router();
|
||||
router.use(authUserCheck(false));
|
||||
|
||||
interface OperatorData {
|
||||
name: string[]
|
||||
display?: string
|
||||
friendly?: string
|
||||
}
|
||||
|
||||
export const heartbeat = (opData: OperatorData) => {
|
||||
const response = async (req: Request, res: Response) => {
|
||||
if(req.botApp === undefined) {
|
||||
return res.status(500).send('Application is initializing, try again in a few seconds');
|
||||
}
|
||||
const heartbeatData = {
|
||||
subreddits: req.botApp.bots.map(y => y.subManagers.map(x => x.subreddit.display_name)).flat(),
|
||||
bots: req.botApp.bots.map(x => ({botName: x.botName, subreddits: x.subManagers.map(y => y.displayLabel), running: x.running})),
|
||||
operators: opData.name,
|
||||
operatorDisplay: opData.display,
|
||||
friendly: opData.friendly,
|
||||
//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,
|
||||
};
|
||||
return res.json(heartbeatData);
|
||||
};
|
||||
return [authUserCheck(false), response];
|
||||
}
|
||||
96
src/Web/Server/routes/authenticated/user/action.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import express, {Request, Response} from 'express';
|
||||
import {RUNNING, USER} from "../../../../../Common/interfaces";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import LoggedError from "../../../../../Utils/LoggedError";
|
||||
import winston from "winston";
|
||||
import {authUserCheck, botRoute} from "../../../middleware";
|
||||
import {booleanMiddle} from "../../../../Common/middleware";
|
||||
|
||||
const action = async (req: express.Request, res: express.Response) => {
|
||||
const bot = req.serverBot;
|
||||
|
||||
const {type, action, subreddit, force = false} = req.query as any;
|
||||
const {name: userName, realManagers = [], isOperator} = req.user as Express.User;
|
||||
let subreddits: string[] = [];
|
||||
if (subreddit === 'All') {
|
||||
subreddits = realManagers;
|
||||
} else if (realManagers.includes(subreddit)) {
|
||||
subreddits = [subreddit];
|
||||
}
|
||||
|
||||
for (const s of subreddits) {
|
||||
const manager = bot.subManagers.find(x => x.displayLabel === s);
|
||||
if (manager === undefined) {
|
||||
winston.loggers.get('app').warn(`Manager for ${s} does not exist`, {subreddit: `/u/${userName}`});
|
||||
continue;
|
||||
}
|
||||
const mLogger = manager.logger;
|
||||
mLogger.info(`/u/${userName} invoked '${action}' action for ${type} on ${manager.displayLabel}`);
|
||||
try {
|
||||
switch (action) {
|
||||
case 'start':
|
||||
if (type === 'bot') {
|
||||
await manager.start('user');
|
||||
} else if (type === 'queue') {
|
||||
manager.startQueue('user');
|
||||
} else {
|
||||
await manager.startEvents('user');
|
||||
}
|
||||
break;
|
||||
case 'stop':
|
||||
if (type === 'bot') {
|
||||
await manager.stop('user');
|
||||
} else if (type === 'queue') {
|
||||
await manager.stopQueue('user');
|
||||
} else {
|
||||
manager.stopEvents('user');
|
||||
}
|
||||
break;
|
||||
case 'pause':
|
||||
if (type === 'queue') {
|
||||
await manager.pauseQueue('user');
|
||||
} else {
|
||||
manager.pauseEvents('user');
|
||||
}
|
||||
break;
|
||||
case 'reload':
|
||||
const prevQueueState = manager.queueState.state;
|
||||
const newConfig = await manager.parseConfiguration('user', force);
|
||||
if (newConfig === false) {
|
||||
mLogger.info('Config was up-to-date');
|
||||
}
|
||||
if (newConfig && prevQueueState === RUNNING) {
|
||||
await manager.startQueue(USER);
|
||||
}
|
||||
break;
|
||||
case 'check':
|
||||
if (type === 'unmoderated') {
|
||||
const activities = await manager.subreddit.getUnmoderated({limit: 100});
|
||||
for (const a of activities.reverse()) {
|
||||
await manager.queue.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const activities = await manager.subreddit.getModqueue({limit: 100});
|
||||
for (const a of activities.reverse()) {
|
||||
await manager.queue.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
});
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
if (!(err instanceof LoggedError)) {
|
||||
mLogger.error(err, {subreddit: manager.displayLabel});
|
||||
}
|
||||
}
|
||||
}
|
||||
res.send('OK');
|
||||
};
|
||||
|
||||
const actionRoute = [authUserCheck(), botRoute(), booleanMiddle(['force']), action];
|
||||
export default actionRoute;
|
||||
50
src/Web/Server/routes/authenticated/user/addBot.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {Request, Response} from 'express';
|
||||
import {BotInstanceConfig} from "../../../../../Common/interfaces";
|
||||
import {authUserCheck} from "../../../middleware";
|
||||
import Bot from "../../../../../Bot";
|
||||
import LoggedError from "../../../../../Utils/LoggedError";
|
||||
|
||||
const addBot = () => {
|
||||
|
||||
const middleware = [
|
||||
authUserCheck(),
|
||||
];
|
||||
|
||||
const response = async (req: Request, res: Response) => {
|
||||
|
||||
if (!(req.user as Express.User).isOperator) {
|
||||
return res.status(401).send("Must be an Operator to use this route");
|
||||
}
|
||||
|
||||
const newBot = new Bot(req.body as BotInstanceConfig, req.botApp.logger);
|
||||
req.botApp.bots.push(newBot);
|
||||
let result: any = {stored: true};
|
||||
try {
|
||||
if (newBot.error !== undefined) {
|
||||
result.error = newBot.error;
|
||||
return res.status(500).json(result);
|
||||
}
|
||||
await newBot.testClient();
|
||||
await newBot.buildManagers();
|
||||
newBot.runManagers('user').catch((err) => {
|
||||
req.botApp.logger.error(`Unexpected error occurred while running Bot ${newBot.botName}. Bot must be re-built to restart`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
req.botApp.logger.error(err);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (newBot.error === undefined) {
|
||||
newBot.error = err.message;
|
||||
result.error = err.message;
|
||||
}
|
||||
req.botApp.logger.error(`Bot ${newBot.botName} cannot recover from this error and must be re-built`);
|
||||
if (!err.logged || !(err instanceof LoggedError)) {
|
||||
req.botApp.logger.error(err);
|
||||
}
|
||||
}
|
||||
return res.json(result);
|
||||
}
|
||||
return [...middleware, response];
|
||||
}
|
||||
|
||||
export default addBot;
|
||||
77
src/Web/Server/routes/authenticated/user/index.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {Request, Response} from 'express';
|
||||
import {authUserCheck, botRoute} from "../../../middleware";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import winston from 'winston';
|
||||
import {COMMENT_URL_ID, parseLinkIdentifier, SUBMISSION_URL_ID} from "../../../../../util";
|
||||
import {booleanMiddle} from "../../../../Common/middleware";
|
||||
|
||||
const commentReg = parseLinkIdentifier([COMMENT_URL_ID]);
|
||||
const submissionReg = parseLinkIdentifier([SUBMISSION_URL_ID]);
|
||||
|
||||
const config = async (req: Request, res: Response) => {
|
||||
const bot = req.serverBot;
|
||||
|
||||
const {subreddit} = req.query as any;
|
||||
const {name: userName, realManagers = [], isOperator} = req.user as Express.User;
|
||||
if (!isOperator && !realManagers.includes(subreddit)) {
|
||||
return res.status(400).send('Cannot retrieve config for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
const manager = bot.subManagers.find(x => x.displayLabel === subreddit);
|
||||
if (manager === undefined) {
|
||||
return res.status(400).send('Cannot retrieve config for subreddit you do not manage or is not run by the bot')
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const wiki = await manager.subreddit.getWikiPage(manager.wikiLocation).fetch();
|
||||
return res.send(wiki.content_md);
|
||||
};
|
||||
export const configRoute = [authUserCheck(), botRoute(), config];
|
||||
|
||||
const action = async (req: Request, res: Response) => {
|
||||
const bot = req.serverBot;
|
||||
|
||||
const {url, dryRun, subreddit} = req.query as any;
|
||||
const {name: userName, realManagers = [], isOperator} = req.user as Express.User;
|
||||
|
||||
let a;
|
||||
const commentId = commentReg(url);
|
||||
if (commentId !== undefined) {
|
||||
// @ts-ignore
|
||||
a = await bot.client.getComment(commentId);
|
||||
}
|
||||
if (a === undefined) {
|
||||
const submissionId = submissionReg(url);
|
||||
if (submissionId !== undefined) {
|
||||
// @ts-ignore
|
||||
a = await bot.client.getSubmission(submissionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (a === undefined) {
|
||||
winston.loggers.get('app').error('Could not parse Comment or Submission ID from given URL', {subreddit: `/u/${userName}`});
|
||||
return res.send('OK');
|
||||
} else {
|
||||
// @ts-ignore
|
||||
const activity = await a.fetch();
|
||||
const sub = await activity.subreddit.display_name;
|
||||
|
||||
let manager = subreddit === 'All' ? bot.subManagers.find(x => x.subreddit.display_name === sub) : bot.subManagers.find(x => x.displayLabel === subreddit);
|
||||
|
||||
if (manager === undefined || (!realManagers.includes(manager.displayLabel))) {
|
||||
let msg = 'Activity does not belong to a subreddit you moderate or the bot runs on.';
|
||||
if (subreddit === 'All') {
|
||||
msg = `${msg} If you want to test an Activity against a Subreddit\'s config it does not belong to then switch to that Subreddit's tab first.`
|
||||
}
|
||||
winston.loggers.get('app').error(msg, {subreddit: `/u/${userName}`});
|
||||
return res.send('OK');
|
||||
}
|
||||
|
||||
// will run dryrun if specified or if running activity on subreddit it does not belong to
|
||||
const dr: boolean | undefined = (dryRun || manager.subreddit.display_name !== sub) ? true : undefined;
|
||||
manager.logger.info(`/u/${userName} running${dr === true ? ' DRY RUN ' : ' '}check on${manager.subreddit.display_name !== sub ? ' FOREIGN ACTIVITY ' : ' '}${url}`);
|
||||
await manager.runChecks(activity instanceof Submission ? 'Submission' : 'Comment', activity, {dryRun: dr})
|
||||
}
|
||||
res.send('OK');
|
||||
};
|
||||
|
||||
export const actionRoute = [authUserCheck(), botRoute(), booleanMiddle(['dryRun']), action];
|
||||
76
src/Web/Server/routes/authenticated/user/logs.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {Router} from '@awaitjs/express';
|
||||
import {Request, Response} from 'express';
|
||||
import {filterLogBySubreddit, isLogLineMinLevel, LogEntry, parseSubredditLogName} from "../../../../../util";
|
||||
import {Transform} from "stream";
|
||||
import winston from "winston";
|
||||
import pEvent from "p-event";
|
||||
import {getLogger} from "../../../../../Utils/loggerFactory";
|
||||
import {booleanMiddle} from "../../../../Common/middleware";
|
||||
import {authUserCheck, botRoute} from "../../../middleware";
|
||||
import {LogInfo} from "../../../../../Common/interfaces";
|
||||
import {MESSAGE} from "triple-beam";
|
||||
|
||||
// TODO update logs api
|
||||
const logs = (subLogMap: Map<string, LogEntry[]>) => {
|
||||
const middleware = [
|
||||
authUserCheck(),
|
||||
booleanMiddle([{
|
||||
name: 'stream',
|
||||
defaultVal: false
|
||||
}])
|
||||
];
|
||||
|
||||
const response = async (req: Request, res: Response) => {
|
||||
|
||||
const logger = winston.loggers.get('app');
|
||||
|
||||
const {name: userName, realManagers = [], isOperator} = req.user as Express.User;
|
||||
const {level = 'verbose', stream, limit = 200, sort = 'descending', streamObjects = false} = req.query;
|
||||
if (stream) {
|
||||
const origin = req.header('X-Forwarded-For') ?? req.header('host');
|
||||
try {
|
||||
logger.stream().on('log', (log: LogInfo) => {
|
||||
if (isLogLineMinLevel(log, level as string)) {
|
||||
const {subreddit: subName} = log;
|
||||
if (isOperator || (subName !== undefined && (realManagers.includes(subName) || subName.includes(userName)))) {
|
||||
if(streamObjects) {
|
||||
res.write(`${JSON.stringify(log)}\r\n`);
|
||||
} else {
|
||||
res.write(`${log[MESSAGE]}\r\n`)
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
logger.info(`${userName} from ${origin} => CONNECTED`);
|
||||
await pEvent(req, 'close');
|
||||
console.log('Request closed detected with "close" listener');
|
||||
res.destroy();
|
||||
return;
|
||||
} catch (e) {
|
||||
if (e.code !== 'ECONNRESET') {
|
||||
logger.error(e);
|
||||
}
|
||||
} finally {
|
||||
logger.info(`${userName} from ${origin} => DISCONNECTED`);
|
||||
res.destroy();
|
||||
}
|
||||
} else {
|
||||
const logs = filterLogBySubreddit(subLogMap, realManagers, {
|
||||
level: (level as string),
|
||||
operator: isOperator,
|
||||
user: userName,
|
||||
sort: sort as 'descending' | 'ascending',
|
||||
limit: Number.parseInt((limit as string))
|
||||
});
|
||||
const subArr: any = [];
|
||||
logs.forEach((v: string[], k: string) => {
|
||||
subArr.push({name: k, logs: v.join('')});
|
||||
});
|
||||
return res.json(subArr);
|
||||
}
|
||||
};
|
||||
|
||||
return [...middleware, response];
|
||||
}
|
||||
|
||||
export default logs;
|
||||
305
src/Web/Server/routes/authenticated/user/status.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import {Request, Response} from 'express';
|
||||
import {
|
||||
boolToString,
|
||||
cacheStats,
|
||||
filterLogBySubreddit,
|
||||
formatNumber,
|
||||
intersect,
|
||||
LogEntry,
|
||||
pollingInfo
|
||||
} from "../../../../../util";
|
||||
import {Manager} from "../../../../../Subreddit/Manager";
|
||||
import dayjs from "dayjs";
|
||||
import {ResourceStats, RUNNING, STOPPED, SYSTEM} from "../../../../../Common/interfaces";
|
||||
import {BotStatusResponse} from "../../../../Common/interfaces";
|
||||
import winston from "winston";
|
||||
import {opStats} from "../../../../Common/util";
|
||||
import {authUserCheck, botRoute} from "../../../middleware";
|
||||
import Bot from "../../../../../Bot";
|
||||
|
||||
const status = () => {
|
||||
|
||||
const middleware = [
|
||||
authUserCheck(),
|
||||
//botRoute(),
|
||||
];
|
||||
|
||||
const response = async (req: Request, res: Response) => {
|
||||
let bots: Bot[] = [];
|
||||
const {
|
||||
limit = 200,
|
||||
level = 'verbose',
|
||||
sort = 'descending',
|
||||
} = req.query;
|
||||
|
||||
// @ts-ignore
|
||||
const botLogMap = req.botLogs as Map<string, Map<string, LogEntry[]>>;
|
||||
// @ts-ignore
|
||||
const systemLogs = req.systemLogs as LogEntry[];
|
||||
|
||||
|
||||
if(req.serverBot !== undefined) {
|
||||
bots = [req.serverBot];
|
||||
} else {
|
||||
bots = (req.user as Express.User).isOperator ? req.botApp.bots : req.botApp.bots.filter(x => intersect(req.user?.subreddits as string[], x.subManagers.map(y => y.subreddit.display_name)));
|
||||
}
|
||||
const botResponses: BotStatusResponse[] = [];
|
||||
for(const b of bots) {
|
||||
botResponses.push(await botStatResponse(b, req, botLogMap));
|
||||
}
|
||||
const system: any = {};
|
||||
if((req.user as Express.User).isOperator) {
|
||||
// @ts-ignore
|
||||
system.logs = filterLogBySubreddit(new Map([['app', systemLogs]]), [], {level, sort, limit, operator: true}).get('app');
|
||||
}
|
||||
const response = {
|
||||
bots: botResponses,
|
||||
system: system,
|
||||
};
|
||||
return res.json(response);
|
||||
}
|
||||
|
||||
const botStatResponse = async (bot: Bot, req: Request, botLogMap: Map<string, Map<string, LogEntry[]>>) => {
|
||||
const {
|
||||
//subreddits = [],
|
||||
//user: userVal,
|
||||
limit = 200,
|
||||
level = 'verbose',
|
||||
sort = 'descending',
|
||||
lastCheck
|
||||
} = req.query;
|
||||
|
||||
const {name: userName, realManagers = [], isOperator} = req.user as Express.User;
|
||||
const user = userName as string;
|
||||
const subreddits = realManagers;
|
||||
//const isOperator = opNames.includes(user.toLowerCase())
|
||||
|
||||
const logs = filterLogBySubreddit(botLogMap.get(bot.botName as string) || new Map(), realManagers, {
|
||||
level: (level as string),
|
||||
operator: isOperator,
|
||||
user,
|
||||
// @ts-ignore
|
||||
sort,
|
||||
limit: Number.parseInt((limit as string))
|
||||
});
|
||||
|
||||
const subManagerData = [];
|
||||
for (const s of subreddits) {
|
||||
const m = bot.subManagers.find(x => x.displayLabel === s) as Manager;
|
||||
if(m === undefined) {
|
||||
continue;
|
||||
}
|
||||
const sd = {
|
||||
name: s,
|
||||
//linkName: s.replace(/\W/g, ''),
|
||||
logs: logs.get(s) || [], // provide a default empty value in case we truly have not logged anything for this subreddit yet
|
||||
botState: m.botState,
|
||||
eventsState: m.eventsState,
|
||||
queueState: m.queueState,
|
||||
indicator: 'gray',
|
||||
queuedActivities: m.queue.length(),
|
||||
runningActivities: m.queue.running(),
|
||||
maxWorkers: m.queue.concurrency,
|
||||
subMaxWorkers: m.subMaxWorkers || bot.maxWorkers,
|
||||
globalMaxWorkers: bot.maxWorkers,
|
||||
validConfig: boolToString(m.validConfigLoaded),
|
||||
dryRun: boolToString(m.dryRun === true),
|
||||
pollingInfo: m.pollOptions.length === 0 ? ['nothing :('] : m.pollOptions.map(pollingInfo),
|
||||
checks: {
|
||||
submissions: m.submissionChecks === undefined ? 0 : m.submissionChecks.length,
|
||||
comments: m.commentChecks === undefined ? 0 : m.commentChecks.length,
|
||||
},
|
||||
wikiLocation: m.wikiLocation,
|
||||
wikiHref: `https://reddit.com/r/${m.subreddit.display_name}/wiki/${m.wikiLocation}`,
|
||||
wikiRevisionHuman: m.lastWikiRevision === undefined ? 'N/A' : `${dayjs.duration(dayjs().diff(m.lastWikiRevision)).humanize()} ago`,
|
||||
wikiRevision: m.lastWikiRevision === undefined ? 'N/A' : m.lastWikiRevision.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
wikiLastCheckHuman: `${dayjs.duration(dayjs().diff(m.lastWikiCheck)).humanize()} ago`,
|
||||
wikiLastCheck: m.lastWikiCheck.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
stats: await m.getStats(),
|
||||
startedAt: 'Not Started',
|
||||
startedAtHuman: 'Not Started',
|
||||
delayBy: m.delayBy === undefined ? 'No' : `Delayed by ${m.delayBy} sec`,
|
||||
};
|
||||
// TODO replace indicator data with js on client page
|
||||
let indicator;
|
||||
if (m.botState.state === RUNNING && m.queueState.state === RUNNING && m.eventsState.state === RUNNING) {
|
||||
indicator = 'green';
|
||||
} else if (m.botState.state === STOPPED && m.queueState.state === STOPPED && m.eventsState.state === STOPPED) {
|
||||
indicator = 'red';
|
||||
} else {
|
||||
indicator = 'yellow';
|
||||
}
|
||||
sd.indicator = indicator;
|
||||
if (m.startedAt !== undefined) {
|
||||
const dur = dayjs.duration(dayjs().diff(m.startedAt));
|
||||
sd.startedAtHuman = `${dur.humanize()} ago`;
|
||||
sd.startedAt = m.startedAt.local().format('MMMM D, YYYY h:mm A Z');
|
||||
|
||||
if (sd.stats.cache.totalRequests > 0) {
|
||||
const minutes = dur.asMinutes();
|
||||
if (minutes < 10) {
|
||||
sd.stats.cache.requestRate = formatNumber((10 / minutes) * sd.stats.cache.totalRequests, {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
} else {
|
||||
sd.stats.cache.requestRate = formatNumber(sd.stats.cache.totalRequests / (minutes / 10), {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
sd.stats.cache.requestRate = 0;
|
||||
}
|
||||
}
|
||||
subManagerData.push(sd);
|
||||
}
|
||||
const totalStats = subManagerData.reduce((acc, curr) => {
|
||||
return {
|
||||
checks: {
|
||||
submissions: acc.checks.submissions + curr.checks.submissions,
|
||||
comments: acc.checks.comments + curr.checks.comments,
|
||||
},
|
||||
eventsCheckedTotal: acc.eventsCheckedTotal + curr.stats.eventsCheckedTotal,
|
||||
checksRunTotal: acc.checksRunTotal + curr.stats.checksRunTotal,
|
||||
checksTriggeredTotal: acc.checksTriggeredTotal + curr.stats.checksTriggeredTotal,
|
||||
rulesRunTotal: acc.rulesRunTotal + curr.stats.rulesRunTotal,
|
||||
rulesCachedTotal: acc.rulesCachedTotal + curr.stats.rulesCachedTotal,
|
||||
rulesTriggeredTotal: acc.rulesTriggeredTotal + curr.stats.rulesTriggeredTotal,
|
||||
actionsRunTotal: acc.actionsRunTotal + curr.stats.actionsRunTotal,
|
||||
maxWorkers: acc.maxWorkers + curr.maxWorkers,
|
||||
subMaxWorkers: acc.subMaxWorkers + curr.subMaxWorkers,
|
||||
globalMaxWorkers: acc.globalMaxWorkers + curr.globalMaxWorkers,
|
||||
runningActivities: acc.runningActivities + curr.runningActivities,
|
||||
queuedActivities: acc.queuedActivities + curr.queuedActivities,
|
||||
};
|
||||
}, {
|
||||
checks: {
|
||||
submissions: 0,
|
||||
comments: 0,
|
||||
},
|
||||
eventsCheckedTotal: 0,
|
||||
checksRunTotal: 0,
|
||||
checksTriggeredTotal: 0,
|
||||
rulesRunTotal: 0,
|
||||
rulesCachedTotal: 0,
|
||||
rulesTriggeredTotal: 0,
|
||||
actionsRunTotal: 0,
|
||||
maxWorkers: 0,
|
||||
subMaxWorkers: 0,
|
||||
globalMaxWorkers: 0,
|
||||
runningActivities: 0,
|
||||
queuedActivities: 0,
|
||||
});
|
||||
const {
|
||||
checks,
|
||||
maxWorkers,
|
||||
globalMaxWorkers,
|
||||
subMaxWorkers,
|
||||
runningActivities,
|
||||
queuedActivities,
|
||||
...rest
|
||||
} = totalStats;
|
||||
|
||||
let cumRaw = subManagerData.reduce((acc, curr) => {
|
||||
Object.keys(curr.stats.cache.types as ResourceStats).forEach((k) => {
|
||||
acc[k].requests += curr.stats.cache.types[k].requests;
|
||||
acc[k].miss += curr.stats.cache.types[k].miss;
|
||||
// @ts-ignore
|
||||
acc[k].identifierAverageHit += (typeof curr.stats.cache.types[k].identifierAverageHit === 'string' ? Number.parseFloat(curr.stats.cache.types[k].identifierAverageHit) : curr.stats.cache.types[k].identifierAverageHit);
|
||||
acc[k].averageTimeBetweenHits += curr.stats.cache.types[k].averageTimeBetweenHits === 'N/A' ? 0 : Number.parseFloat(curr.stats.cache.types[k].averageTimeBetweenHits)
|
||||
});
|
||||
return acc;
|
||||
}, cacheStats());
|
||||
cumRaw = Object.keys(cumRaw).reduce((acc, curr) => {
|
||||
const per = acc[curr].miss === 0 ? 0 : formatNumber(acc[curr].miss / acc[curr].requests) * 100;
|
||||
// @ts-ignore
|
||||
acc[curr].missPercent = `${formatNumber(per, {toFixed: 0})}%`;
|
||||
acc[curr].identifierAverageHit = formatNumber(acc[curr].identifierAverageHit);
|
||||
acc[curr].averageTimeBetweenHits = formatNumber(acc[curr].averageTimeBetweenHits)
|
||||
return acc;
|
||||
}, cumRaw);
|
||||
const cacheReq = subManagerData.reduce((acc, curr) => acc + curr.stats.cache.totalRequests, 0);
|
||||
const cacheMiss = subManagerData.reduce((acc, curr) => acc + curr.stats.cache.totalMiss, 0);
|
||||
const aManagerWithDefaultResources = bot.subManagers.find(x => x.resources !== undefined && x.resources.cacheSettingsHash === 'default');
|
||||
let allManagerData: any = {
|
||||
name: 'All',
|
||||
status: bot.running ? 'RUNNING' : 'NOT RUNNING',
|
||||
indicator: bot.running ? 'green' : 'grey',
|
||||
maxWorkers,
|
||||
globalMaxWorkers,
|
||||
subMaxWorkers,
|
||||
runningActivities,
|
||||
queuedActivities,
|
||||
botState: {
|
||||
state: RUNNING,
|
||||
causedBy: SYSTEM
|
||||
},
|
||||
dryRun: boolToString(bot.dryRun === true),
|
||||
logs: logs.get('all'),
|
||||
checks: checks,
|
||||
softLimit: bot.softLimit,
|
||||
hardLimit: bot.hardLimit,
|
||||
stats: {
|
||||
...rest,
|
||||
cache: {
|
||||
currentKeyCount: aManagerWithDefaultResources !== undefined ? await aManagerWithDefaultResources.resources.getCacheKeyCount() : 'N/A',
|
||||
isShared: false,
|
||||
totalRequests: cacheReq,
|
||||
totalMiss: cacheMiss,
|
||||
missPercent: `${formatNumber(cacheMiss === 0 || cacheReq === 0 ? 0 : (cacheMiss / cacheReq) * 100, {toFixed: 0})}%`,
|
||||
types: {
|
||||
...cumRaw,
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
if (allManagerData.logs === undefined) {
|
||||
// this should happen but saw an edge case where potentially did
|
||||
winston.loggers.get('app').warn(`Logs for 'all' were undefined found but should always have a default empty value`);
|
||||
}
|
||||
// if(isOperator) {
|
||||
allManagerData.startedAt = bot.startedAt.local().format('MMMM D, YYYY h:mm A Z');
|
||||
allManagerData.heartbeatHuman = dayjs.duration({seconds: bot.heartbeatInterval}).humanize();
|
||||
allManagerData.heartbeat = bot.heartbeatInterval;
|
||||
allManagerData = {...allManagerData, ...opStats(bot)};
|
||||
//}
|
||||
|
||||
const botDur = dayjs.duration(dayjs().diff(bot.startedAt))
|
||||
if (allManagerData.stats.cache.totalRequests > 0) {
|
||||
const minutes = botDur.asMinutes();
|
||||
if (minutes < 10) {
|
||||
allManagerData.stats.cache.requestRate = formatNumber((10 / minutes) * allManagerData.stats.cache.totalRequests, {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
} else {
|
||||
allManagerData.stats.cache.requestRate = formatNumber(allManagerData.stats.cache.totalRequests / (minutes / 10), {
|
||||
toFixed: 0,
|
||||
round: {enable: true, indicate: true}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
allManagerData.stats.cache.requestRate = 0;
|
||||
}
|
||||
|
||||
const data: BotStatusResponse = {
|
||||
system: {
|
||||
startedAt: bot.startedAt.local().format('MMMM D, YYYY h:mm A Z'),
|
||||
running: bot.running,
|
||||
error: bot.error,
|
||||
account: bot.botAccount as string,
|
||||
name: bot.botName as string,
|
||||
...opStats(bot),
|
||||
},
|
||||
subreddits: [allManagerData, ...subManagerData],
|
||||
|
||||
};
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
return [...middleware, response];
|
||||
}
|
||||
|
||||
export default status;
|
||||
230
src/Web/Server/server.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import {addAsync, Router} from '@awaitjs/express';
|
||||
import express, {Request, Response} from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import {App} from "../../App";
|
||||
import {Transform} from "stream";
|
||||
import winston from 'winston';
|
||||
import {Server as SocketServer} from 'socket.io';
|
||||
import {Strategy as JwtStrategy, ExtractJwt} from 'passport-jwt';
|
||||
import passport from 'passport';
|
||||
import tcpUsed from 'tcp-port-used';
|
||||
|
||||
import {
|
||||
intersect,
|
||||
LogEntry, parseBotLogName,
|
||||
parseSubredditLogName
|
||||
} from "../../util";
|
||||
import {getLogger} from "../../Utils/loggerFactory";
|
||||
import LoggedError from "../../Utils/LoggedError";
|
||||
import {Invokee, LogInfo, OperatorConfig} from "../../Common/interfaces";
|
||||
import http from "http";
|
||||
import SimpleError from "../../Utils/SimpleError";
|
||||
import {heartbeat} from "./routes/authenticated/applicationRoutes";
|
||||
import logs from "./routes/authenticated/user/logs";
|
||||
import status from './routes/authenticated/user/status';
|
||||
import {actionRoute, configRoute} from "./routes/authenticated/user";
|
||||
import action from "./routes/authenticated/user/action";
|
||||
import {authUserCheck, botRoute} from "./middleware";
|
||||
import {opStats} from "../Common/util";
|
||||
import Bot from "../../Bot";
|
||||
import addBot from "./routes/authenticated/user/addBot";
|
||||
import dayjs from "dayjs";
|
||||
|
||||
const server = addAsync(express());
|
||||
server.use(bodyParser.json());
|
||||
server.use(bodyParser.urlencoded({extended: false}));
|
||||
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user: string,
|
||||
subreddits: string[],
|
||||
lastCheck?: number,
|
||||
limit?: number,
|
||||
sort?: string,
|
||||
level?: string,
|
||||
}
|
||||
}
|
||||
|
||||
const subLogMap: Map<string, LogEntry[]> = new Map();
|
||||
const systemLogs: LogEntry[] = [];
|
||||
const botLogMap: Map<string, Map<string, LogEntry[]>> = new Map();
|
||||
|
||||
const botSubreddits: Map<string, string[]> = new Map();
|
||||
|
||||
const rcbServer = async function (options: OperatorConfig) {
|
||||
|
||||
const {
|
||||
operator: {
|
||||
name,
|
||||
display,
|
||||
},
|
||||
api: {
|
||||
secret: secret,
|
||||
port,
|
||||
friendly,
|
||||
}
|
||||
} = options;
|
||||
|
||||
const opNames = name.map(x => x.toLowerCase());
|
||||
let app: App;
|
||||
|
||||
const logger = getLogger({...options.logging});
|
||||
|
||||
logger.stream().on('log', (log: LogInfo) => {
|
||||
const logEntry: LogEntry = [dayjs(log.timestamp).unix(), log];
|
||||
|
||||
const {bot: botName, subreddit: subName} = log;
|
||||
|
||||
if(botName === undefined) {
|
||||
systemLogs.unshift(logEntry);
|
||||
systemLogs.slice(0, 201);
|
||||
} else {
|
||||
const botLog = botLogMap.get(botName) || new Map();
|
||||
|
||||
if(subName === undefined) {
|
||||
const appLogs = botLog.get('app') || [];
|
||||
appLogs.unshift(logEntry);
|
||||
botLog.set('app', appLogs.slice(0, 200 + 1));
|
||||
} else {
|
||||
let botSubs = botSubreddits.get(botName) || [];
|
||||
if(botSubs.length === 0 && app !== undefined) {
|
||||
const b = app.bots.find(x => x.botName === botName);
|
||||
if(b !== undefined) {
|
||||
botSubs = b.subManagers.map(x => x.displayLabel);
|
||||
botSubreddits.set(botName, botSubs);
|
||||
}
|
||||
}
|
||||
if(botSubs.length === 0 || botSubs.includes(subName)) {
|
||||
const subLogs = botLog.get(subName) || [];
|
||||
subLogs.unshift(logEntry);
|
||||
botLog.set(subName, subLogs.slice(0, 200 + 1));
|
||||
}
|
||||
}
|
||||
botLogMap.set(botName, botLog);
|
||||
}
|
||||
})
|
||||
|
||||
if (await tcpUsed.check(port)) {
|
||||
throw new SimpleError(`Specified port for API (${port}) is in use or not available. Cannot start API.`);
|
||||
}
|
||||
|
||||
let httpServer: http.Server,
|
||||
io: SocketServer;
|
||||
|
||||
try {
|
||||
httpServer = await server.listen(port);
|
||||
io = new SocketServer(httpServer);
|
||||
} catch (err) {
|
||||
logger.error('Error occurred while initializing web or socket.io server', err);
|
||||
err.logged = true;
|
||||
throw err;
|
||||
}
|
||||
|
||||
logger.info(`API started => localhost:${port}`);
|
||||
|
||||
passport.use(new JwtStrategy({
|
||||
secretOrKey: secret,
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
}, function (jwtPayload, done) {
|
||||
const {name, subreddits = [], machine = true} = jwtPayload.data;
|
||||
if (machine) {
|
||||
return done(null, {machine});
|
||||
}
|
||||
const isOperator = opNames.includes(name.toLowerCase());
|
||||
let moderatedBots: string[] = [];
|
||||
let moderatedManagers: string[] = [];
|
||||
let realBots: string[] = [];
|
||||
let realManagers: string[] = [];
|
||||
if(app !== undefined) {
|
||||
const modBots = app.bots.filter(x => intersect(subreddits, x.subManagers.map(y => y.subreddit.display_name)));
|
||||
moderatedBots = modBots.map(x => x.botName as string);
|
||||
moderatedManagers = [...new Set(modBots.map(x => x.subManagers.map(y => y.displayLabel)).flat())];
|
||||
realBots = isOperator ? app.bots.map(x => x.botName as string) : moderatedBots;
|
||||
realManagers = isOperator ? [...new Set(app.bots.map(x => x.subManagers.map(y => y.displayLabel)).flat())] : moderatedManagers
|
||||
}
|
||||
|
||||
return done(null, {
|
||||
name,
|
||||
subreddits,
|
||||
isOperator,
|
||||
machine: false,
|
||||
moderatedManagers,
|
||||
realManagers,
|
||||
moderatedBots,
|
||||
realBots,
|
||||
});
|
||||
}));
|
||||
|
||||
server.use(passport.authenticate('jwt', {session: false}));
|
||||
server.use((req, res, next) => {
|
||||
req.botApp = app;
|
||||
next();
|
||||
});
|
||||
|
||||
server.getAsync('/heartbeat', ...heartbeat({name, display, friendly}));
|
||||
|
||||
server.getAsync('/logs', ...logs(subLogMap));
|
||||
|
||||
server.getAsync('/stats', [authUserCheck(), botRoute(false)], async (req: Request, res: Response) => {
|
||||
let bots: Bot[] = [];
|
||||
if(req.serverBot !== undefined) {
|
||||
bots = [req.serverBot];
|
||||
} else {
|
||||
bots = (req.user as Express.User).isOperator ? req.botApp.bots : req.botApp.bots.filter(x => intersect(req.user?.subreddits as string[], x.subManagers.map(y => y.subreddit.display_name)));
|
||||
}
|
||||
const resp = [];
|
||||
for(const b of bots) {
|
||||
resp.push({name: b.botName, data: await opStats(b)});
|
||||
}
|
||||
return res.json(resp);
|
||||
});
|
||||
const passLogs = async (req: Request, res: Response, next: Function) => {
|
||||
// @ts-ignore
|
||||
req.botLogs = botLogMap;
|
||||
// @ts-ignore
|
||||
req.systemLogs = systemLogs;
|
||||
next();
|
||||
}
|
||||
server.getAsync('/status', passLogs, ...status())
|
||||
|
||||
server.getAsync('/config', ...configRoute);
|
||||
|
||||
server.getAsync('/action', ...action);
|
||||
|
||||
server.getAsync('/check', ...actionRoute);
|
||||
|
||||
server.getAsync('/addBot', ...addBot());
|
||||
|
||||
const initBot = async (causedBy: Invokee = 'system') => {
|
||||
if(app !== undefined) {
|
||||
logger.info('A bot instance already exists. Attempting to stop event/queue processing first before building new bot.');
|
||||
await app.destroy(causedBy);
|
||||
}
|
||||
const newApp = new App(options);
|
||||
if(newApp.error === undefined) {
|
||||
try {
|
||||
await newApp.initBots(causedBy);
|
||||
} catch (err) {
|
||||
if(newApp.error === undefined) {
|
||||
newApp.error = err.message;
|
||||
}
|
||||
logger.error('Server is still ONLINE but bot cannot recover from this error and must be re-built');
|
||||
if(!err.logged || !(err instanceof LoggedError)) {
|
||||
logger.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return newApp;
|
||||
}
|
||||
|
||||
server.postAsync('/init', authUserCheck(), async (req, res) => {
|
||||
logger.info(`${(req.user as Express.User).name} requested the app to be re-built. Starting rebuild now...`, {subreddit: (req.user as Express.User).name});
|
||||
app = await initBot('user');
|
||||
});
|
||||
|
||||
logger.info('Beginning bot init on startup...');
|
||||
app = await initBot();
|
||||
};
|
||||
|
||||
export default rcbServer;
|
||||
|
||||
@@ -24,6 +24,13 @@ a {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.nestedTabs {
|
||||
display: none;
|
||||
}
|
||||
.nestedTabs.active {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
/*https://stackoverflow.com/a/48386400/1469797*/
|
||||
.stats {
|
||||
display: grid;
|
||||
@@ -74,3 +81,13 @@ a {
|
||||
.botStats.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sub.offline a {
|
||||
pointer-events: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sub.offline .logs a {
|
||||
pointer-events: initial;
|
||||
text-decoration: initial;
|
||||
}
|
||||
@@ -14,26 +14,21 @@
|
||||
<li>Access Token: <b><%= accessToken %></b></li>
|
||||
<li>Refresh Token: <b><%= refreshToken %></b></li>
|
||||
</ul>
|
||||
<div>Copy these somewhere and then restart the application providing these as either arguments
|
||||
or environmental variables as described in the <a
|
||||
href="https://github.com/FoxxMD/context-mod#usage">usage section.</a>
|
||||
<% if(locals.addResult !== undefined) { %>
|
||||
<div>Result of trying to add bot automatically: <%= addResult %></div>
|
||||
<% } else { %>
|
||||
<div>Bot was not automatically added to an instance and will need to manually appended to configuration...</div>
|
||||
<% } %>
|
||||
<div>If you are a <b>Moderator</b> then copy the above <b>Tokens</b> and pass them on to the Operator of this ContextMod instance.</div>
|
||||
<div>If you are an <b>Operator</b> copy these somewhere and then restart the application providing these as either arguments, environmental variables, or in a json config as described in the <a
|
||||
href="https://github.com/FoxxMD/context-mod/blob/master/docs/operatorConfiguration.md#defining-configuration">configuration guide</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<script>
|
||||
if (document.querySelector('#redirectUri').value === '') {
|
||||
document.querySelector('#redirectUri').value = `${document.location.href}callback`;
|
||||
}
|
||||
|
||||
document.querySelector('#doAuth').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const url = `${document.location.href}auth?redirect=${document.querySelector('#redirectUri').value}`
|
||||
window.location.href = url;
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
174
src/Web/assets/views/config.ejs
Normal file
@@ -0,0 +1,174 @@
|
||||
<html>
|
||||
<head>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind.min.css"
|
||||
integrity="sha512-wl80ucxCRpLkfaCnbM88y4AxnutbGk327762eM9E/rRTvY/ZGAHWMZrYUq66VQBYMIYDFpDdJAOGSLyIPHZ2IQ=="
|
||||
crossorigin="anonymous"/>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.0.3/tailwind-dark.min.css"
|
||||
integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg=="
|
||||
crossorigin="anonymous"/>
|
||||
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
|
||||
<link rel="stylesheet" href="/public/themeToggle.css">
|
||||
<link rel="stylesheet" href="/public/app.css">
|
||||
<title><%= title %></title>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<!--icons from https://heroicons.com -->
|
||||
</head>
|
||||
<body style="user-select: none;" class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/title') %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="dark:text-white mb-3 pl-2">
|
||||
<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'>
|
||||
<div>Copy + paste your configuration here to get:</div>
|
||||
<ul class="list-inside list-disc">
|
||||
<li>
|
||||
formatting (right click for menu)
|
||||
</li>
|
||||
<li>
|
||||
JSON syntax assist (red squiggly, hover for info)
|
||||
</li>
|
||||
<li>
|
||||
annotated properties (hover for info)
|
||||
</li>
|
||||
<li id="schemaTypeList"></li>
|
||||
</ul>
|
||||
<div>When done editing hit Ctrl+A (Command+A on macOS) to select all text, then copy + paste back into your wiki/file</div>
|
||||
</span>
|
||||
<span id="schemaType"></span> |
|
||||
<span class="cursor-help">
|
||||
How To Use
|
||||
<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>
|
||||
| <a id="schemaOpen" href="">Open With Operator Schema</a>
|
||||
<div id="error" class="font-semibold"></div>
|
||||
</div>
|
||||
<div style="min-height: 80vh" id="editor"></div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<script>
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (e.target.id === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelector("#themeToggle").checked = localStorage.getItem('ms-dark') !== 'no';
|
||||
document.querySelector("#themeToggle").onchange = (e) => {
|
||||
if (e.target.checked === true) {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="/monaco/dev/vs/loader.js"></script>
|
||||
<script>
|
||||
require.config({ paths: { vs: 'monaco/dev/vs' } });
|
||||
|
||||
const preamble = [
|
||||
'// Copy + paste your configuration here to get',
|
||||
'// formatting, JSON syntax, annotated properties and'
|
||||
];
|
||||
|
||||
var searchParams = new URLSearchParams(window.location.search);
|
||||
|
||||
let schemaType;
|
||||
if(searchParams.get('schema') === 'operator') {
|
||||
schemaType = 'OperatorConfig.json';
|
||||
preamble.push('// automatic validation of your OPERATOR configuration');
|
||||
document.querySelector('#schemaTypeList').innerHTML = 'automatic validation of your OPERATOR configuration (yellow squiggly)';
|
||||
document.querySelector('#schemaType').innerHTML = 'Operator Configuration';
|
||||
document.querySelector('#schemaOpen').href = '/config?schema=subreddit';
|
||||
document.querySelector('#schemaOpen').innerHTML = 'Open with Subreddit Schema';
|
||||
} else {
|
||||
schemaType = 'App.json';
|
||||
preamble.push('// automatic validation of your SUBREDDIT configuration');
|
||||
document.querySelector('#schemaTypeList').innerHTML = 'automatic validation of your SUBREDDIT configuration (yellow squiggly)'
|
||||
document.querySelector('#schemaType').innerHTML = 'Subreddit Configuration';
|
||||
document.querySelector('#schemaOpen').href = '/config?schema=operator';
|
||||
}
|
||||
|
||||
const schemaUri = `${document.location.origin}/schemas/${schemaType}`;
|
||||
|
||||
require(['vs/editor/editor.main'], function () {
|
||||
const modelUri = monaco.Uri.parse("a://b/foo.json");
|
||||
fetch(schemaUri).then((res) => {
|
||||
res.json().then((schemaData) => {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
allowComments: true,
|
||||
trailingCommas: "ignore",
|
||||
schemas: [{
|
||||
uri: schemaUri,
|
||||
fileMatch: [modelUri.toString()],
|
||||
schema: schemaData
|
||||
}]
|
||||
});
|
||||
if(searchParams.get('subreddit') !== null) {
|
||||
fetch(`${document.location.origin}/config/content${document.location.search}`).then((resp) => {
|
||||
if(!resp.ok) {
|
||||
resp.text().then(data => {
|
||||
document.querySelector('#error').innerHTML = `Error occurred while fetching configuration => ${data}`
|
||||
});
|
||||
} else {
|
||||
resp.text().then(data => {
|
||||
var model = monaco.editor.createModel(data, "json", modelUri);
|
||||
var editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
model,
|
||||
theme: 'vs-dark',
|
||||
minimap: {
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
editor;
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
var model = monaco.editor.createModel(preamble.join('\r\n'), "json", modelUri);
|
||||
var editor = monaco.editor.create(document.getElementById('editor'), {
|
||||
model,
|
||||
theme: 'vs-dark',
|
||||
minimap: {
|
||||
enabled: false
|
||||
}
|
||||
});
|
||||
editor;
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,7 +3,7 @@
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/title', {title: ''}) %>
|
||||
<%- include('partials/title', {title: 'Error'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="text-xl mb-4">Oops 😬</div>
|
||||
<div class="space-y-3">
|
||||
<div>Something went wrong while processing that last request:</div>
|
||||
<div><%- error %></div>
|
||||
<div class="space-y-3"><%- error %></div>
|
||||
<% if(locals.operatorDisplay !== undefined && locals.operatorDisplay !== 'Anonymous') { %>
|
||||
<div>Operated By: <%= operatorDisplay %></div>
|
||||
<% } %>
|
||||
@@ -20,6 +20,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
231
src/Web/assets/views/helper.ejs
Normal file
@@ -0,0 +1,231 @@
|
||||
<html>
|
||||
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/title', {title: ' OAuth Helper'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Hi! Looks like you're setting up your bot. To get running:</div>
|
||||
<div class="text-lg text-semibold my-3">1. Set your redirect URL</div>
|
||||
<div class="ml-5">
|
||||
<input id="redirectUri" style="min-width:500px;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2" value="<%= redirectUri %>">
|
||||
<div class="space-y-3">
|
||||
<div>This is the URL Reddit will redirect you to once you have authorized an account to be
|
||||
used
|
||||
with your application.
|
||||
</div>
|
||||
<div>The input field has been pre-filled with either:
|
||||
<ul class="list-inside list-disc">
|
||||
<li>What you provided to the program as an argument/environmental variable or</li>
|
||||
<li>The current URL in your browser that would be used -- if you are using a reverse
|
||||
proxy this may be different so double check
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>Make sure it matches what is found in the <b>redirect uri</b> for your <a
|
||||
target="_blank"
|
||||
href="https://www.reddit.com/prefs/apps">application
|
||||
on Reddit</a> and <b>it must end with "callback"</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">2. Optionally, set <b>Client Id</b> and <b>Client Secret</b>
|
||||
</div>
|
||||
<div class="ml-5">
|
||||
<div class="space-y-2">
|
||||
Leave these fields blank to use the id/secret you provided the application (if any),
|
||||
otherwise
|
||||
fill them in.
|
||||
</div>
|
||||
<input id="clientId" style="min-width:500px;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2"
|
||||
placeholder="<%= locals.clientId !== undefined ? 'Use Provided Client Id' : 'Client Id Not Provided' %>">
|
||||
<input id="clientSecret" style="min-width:500px; display: block;"
|
||||
class="text-black placeholder-gray-500 rounded mt-2 mb-3 p-2"
|
||||
placeholder="<%= locals.clientSecret !== undefined ? 'Use Provided Client Secret' : 'Client Secret Not Provided' %>">
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">3. 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
|
||||
general.</b></div>
|
||||
<div>In all cases the subreddits the bot moderates for will <b>also need to give the bot
|
||||
moderator permissions to do these actions</b> -- this is just an extra precaution if
|
||||
you are super paranoid.
|
||||
</div>
|
||||
<div><b>Note:</b> None of the permissions the bot receive allow it to view/edit the email or
|
||||
password for the account
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div class="my-3">
|
||||
<h3 class="font-semibold">Required</h3>
|
||||
<div>The following permissions are required for the bot to do <i>anything.</i></div>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="identity" name="identity"
|
||||
checked disabled>
|
||||
<label for="identity"><span class="font-mono font-semibold">identity</span> required for
|
||||
the bot to know who it is</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="mysubreddits" name="mysubreddits"
|
||||
checked disabled>
|
||||
<label for="mysubreddits"><span class="font-mono font-semibold">mysubreddits</span>
|
||||
required for the bot to find out what subreddits it moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="read" name="read"
|
||||
checked disabled>
|
||||
<label for="read"><span class="font-mono font-semibold">read</span> required for the bot
|
||||
to be able to access other's activities</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="history" name="history"
|
||||
checked disabled>
|
||||
<label for="history"><span class="font-mono font-semibold">history</span> required for
|
||||
the bot to be able to access other user's history</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="wikiread" name="wikiread"
|
||||
checked disabled>
|
||||
<label for="wikiread"><span class="font-mono font-semibold">wikiread</span> required for
|
||||
the bot to read configurations in the subreddits it moderates</label>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<h3 class="font-semibold">Recommended</h3>
|
||||
<div class="mb-1">The following permissions cover what is necessary for the bot to
|
||||
perform moderation actions
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="privatemessages"
|
||||
name="privatemessages"
|
||||
checked>
|
||||
<label for="privatemessages"><span
|
||||
class="font-mono font-semibold">privatemessages</span> for the bot to send
|
||||
messages as itself</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modmail" name="modmail"
|
||||
checked>
|
||||
<label for="modmail"><span class="font-mono font-semibold">modmail</span> for the bot to
|
||||
send messages as a subreddit</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modflair" name="modflair"
|
||||
checked>
|
||||
<label for="modflair"><span class="font-mono font-semibold">modflair</span> for the bot
|
||||
to flair users and submissions in the subreddits it moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modcontributors"
|
||||
name="modcontributors"
|
||||
checked>
|
||||
<label for="modcontributors"><span
|
||||
class="font-mono font-semibold">modcontributors</span> for the bot to
|
||||
ban/mute users in the subreddits it moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modposts" name="modposts"
|
||||
checked>
|
||||
<label for="modposts"><span class="font-mono font-semibold">modposts</span> for the bot
|
||||
to approve/remove/nsfw/distinguish/etc. submissions/comments in the subreddits it
|
||||
moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="report" name="report"
|
||||
checked>
|
||||
<label for="report"><span class="font-mono font-semibold">report</span> for the bot to
|
||||
be able to report submissions/comments</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="submit" name="submit"
|
||||
checked>
|
||||
<label for="submit"><span class="font-mono font-semibold">submit</span> for the bot to
|
||||
reply to submissions/comments</label>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<h3 class="font-semibold">Optional</h3>
|
||||
<div>The following permissions cover additional functionality ContextMod can use or may
|
||||
in the use future
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="wikiedit" name="wikiedit">
|
||||
<label for="wikiedit"><span class="font-mono font-semibold">wikiedit</span> for the bot
|
||||
to be able to create/edit
|
||||
Toolbox
|
||||
User Notes</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modlog" name="modlog">
|
||||
<label for="modlog"><span class="font-mono font-semibold">modlog</span> for the bot to
|
||||
able to read the moderation log (not currently implemented)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-lg text-semibold my-3">4. <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>
|
||||
<div id="errorWrapper" class="font-semibold hidden">Error: <span id="error"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
if (document.querySelector('#redirectUri').value === '') {
|
||||
document.querySelector('#redirectUri').value = `${document.location.origin}/callback`;
|
||||
}
|
||||
|
||||
document.querySelector('#doAuth').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const permissions = {};
|
||||
|
||||
document.querySelectorAll('.permissionToggle').forEach((el) => {
|
||||
permissions[el.id] = el.checked;
|
||||
});
|
||||
fetch(`${document.location.origin}/auth/create`, {
|
||||
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,
|
||||
code: document.querySelector("#inviteCode").value === '' ? undefined : document.querySelector("#inviteCode").value,
|
||||
permissions,
|
||||
})
|
||||
}).then((resp) => {
|
||||
if(!resp.ok) {
|
||||
document.querySelector("#errorWrapper").classList.remove('hidden');
|
||||
resp.text().then(t => {
|
||||
document.querySelector("#error").innerHTML = t;
|
||||
});
|
||||
} else {
|
||||
document.querySelector("#errorWrapper").classList.add('hidden');
|
||||
document.querySelector("#inviteCode").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>`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
//const url = `${document.location.origin}/auth/init?${params.toString()}`;
|
||||
//window.location.href = url;
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
153
src/Web/assets/views/invite.ejs
Normal file
@@ -0,0 +1,153 @@
|
||||
<html>
|
||||
<%- include('partials/head', {title: 'CM OAuth Helper'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/title', {title: ' OAuth Helper'}) %>
|
||||
<div class="container mx-auto">
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<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="my-2 ml-5">
|
||||
<div class="space-y-3">
|
||||
<div>These are permissions to allow the bot account to perform these actions, <b>in
|
||||
general.</b> They were pre-selected by the user who you received this invitation from.</div>
|
||||
<div>In all cases the subreddit this account moderates for will <b>also need to give the bot
|
||||
moderator permissions to do these actions</b> -- this is just an extra precaution if
|
||||
you are super paranoid.
|
||||
</div>
|
||||
<div><b>Note:</b> None of the permissions the bot receive allow it to view/edit the email or
|
||||
password for the account
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1">
|
||||
<div class="my-3">
|
||||
<h3 class="font-semibold">Required</h3>
|
||||
<div>The following permissions are required for the bot to do <i>anything.</i></div>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="identity" name="identity"
|
||||
disabled>
|
||||
<label for="identity"><span class="font-mono font-semibold">identity</span> required for
|
||||
the bot to know who it is</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="mysubreddits" name="mysubreddits"
|
||||
disabled>
|
||||
<label for="mysubreddits"><span class="font-mono font-semibold">mysubreddits</span>
|
||||
required for the bot to find out what subreddits it moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="read" name="read"
|
||||
disabled>
|
||||
<label for="read"><span class="font-mono font-semibold">read</span> required for the bot
|
||||
to be able to access other's activities</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="history" name="history"
|
||||
disabled>
|
||||
<label for="history"><span class="font-mono font-semibold">history</span> required for
|
||||
the bot to be able to access other user's history</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="wikiread" name="wikiread"
|
||||
disabled>
|
||||
<label for="wikiread"><span class="font-mono font-semibold">wikiread</span> required for
|
||||
the bot to read configurations in the subreddits it moderates</label>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<h3 class="font-semibold">Recommended</h3>
|
||||
<div class="mb-1">The following permissions cover what is necessary for the bot to
|
||||
perform moderation actions
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="privatemessages"
|
||||
name="privatemessages" disabled>
|
||||
<label for="privatemessages"><span
|
||||
class="font-mono font-semibold">privatemessages</span> for the bot to send
|
||||
messages as itself</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modmail" name="modmail" disabled>
|
||||
<label for="modmail"><span class="font-mono font-semibold">modmail</span> for the bot to
|
||||
send messages as a subreddit</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modflair" name="modflair" disabled>
|
||||
<label for="modflair"><span class="font-mono font-semibold">modflair</span> for the bot
|
||||
to flair users and submissions in the subreddits it moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modcontributors"
|
||||
name="modcontributors" disabled>
|
||||
<label for="modcontributors"><span
|
||||
class="font-mono font-semibold">modcontributors</span> for the bot to
|
||||
ban/mute users in the subreddits it moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modposts" name="modposts" disabled>
|
||||
<label for="modposts"><span class="font-mono font-semibold">modposts</span> for the bot
|
||||
to approve/remove/nsfw/distinguish/etc. submissions/comments in the subreddits it
|
||||
moderates</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="report" name="report" disabled>
|
||||
<label for="report"><span class="font-mono font-semibold">report</span> for the bot to
|
||||
be able to report submissions/comments</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="submit" name="submit" disabled>
|
||||
<label for="submit"><span class="font-mono font-semibold">submit</span> for the bot to
|
||||
reply to submissions/comments</label>
|
||||
</div>
|
||||
<div class="my-3">
|
||||
<h3 class="font-semibold">Optional</h3>
|
||||
<div>The following permissions cover additional functionality ContextMod can use or may
|
||||
in the use future
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="wikiedit" name="wikiedit" disabled>
|
||||
<label for="wikiedit"><span class="font-mono font-semibold">wikiedit</span> for the bot
|
||||
to be able to create/edit
|
||||
Toolbox
|
||||
User Notes</label>
|
||||
</div>
|
||||
<div>
|
||||
<input class="permissionToggle" type="checkbox" id="modlog" name="modlog" disabled>
|
||||
<label for="modlog"><span class="font-mono font-semibold">modlog</span> for the bot to
|
||||
able to read the moderation log (not currently implemented)</label>
|
||||
</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="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">3. <a id="doAuth" href="">Authorize the account</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const permissions = <%- permissions %>;
|
||||
|
||||
for(const [k, v] of Object.entries(permissions)) {
|
||||
document.querySelector(`input#${k}`).checked = v;
|
||||
}
|
||||
document.querySelector('#doAuth').addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
const url = `${document.location.origin}/auth/init?invite=<%= invite %>`;
|
||||
window.location.href = url;
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,5 +1,5 @@
|
||||
<html>
|
||||
<%- include('partials/head', {title: 'CM'}) %>
|
||||
<%- include('partials/head', {title: 'Access Denied'}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
@@ -10,13 +10,15 @@
|
||||
<div class="p-6 md:px-10 md:py-6">
|
||||
<div class="text-xl mb-4">Sorry!</div>
|
||||
<div class="space-y-3">
|
||||
<div>Your account was successfully logged in but you do not have access to this ContextMod instance because either:</div>
|
||||
<div>Your account was successfully logged in but you cannot access this ContextMod client or route for one of these reasons:</div>
|
||||
<ul class="list-inside list-disc">
|
||||
<li>The Bot account used by this instance is not a Moderator of any Subreddits you are also a Moderator of or</li>
|
||||
<li>the Bot is a Moderator of one of your Subreddits but the Operator of this instance is not currently running the instance on your Subreddits.</li>
|
||||
<li>You are not a moderator of any of the subreddits this client has access to</li>
|
||||
<li>You are not a moderator of any of the subreddits of the specific instance/bot you are trying to access</li>
|
||||
<li>The instance(s) running the subreddits you are a moderator of are offline</li>
|
||||
<li>The bot(s) running the subreddits you are a moderator of are not configured correctly IE the subreddits they moderate could not be retrieved</li>
|
||||
</ul>
|
||||
<div>Note: You must <a href="logout">Logout</a> in order for the instance to detect changes in your subreddits/moderator status</div>
|
||||
<% if(operatorDisplay !== 'Anonymous') { %>
|
||||
<% if(locals.operatorDisplay !== undefined && locals.operatorDisplay !== 'Anonymous') { %>
|
||||
<div>Operated By: <%= operatorDisplay %></div>
|
||||
<% } %>
|
||||
</div>
|
||||
@@ -24,6 +26,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<%- include('partials/themeJs') %>
|
||||
</body>
|
||||
</html>
|
||||
83
src/Web/assets/views/offline.ejs
Normal file
@@ -0,0 +1,83 @@
|
||||
<html>
|
||||
<%- include('partials/head', {title: undefined}) %>
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/botsTab') %>
|
||||
<div class="container mx-auto">
|
||||
<%- include('partials/subredditsTab') %>
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="pb-6 md:px-7">
|
||||
<div class="sub active" data-subreddit="All" data-bot="All">
|
||||
Instance is currently <b>OFFLINE</b>
|
||||
<div class="flex items-center justify-between flex-wrap">
|
||||
<div class="inline-flex items-center">
|
||||
</div>
|
||||
<%- include('partials/logSettings') %>
|
||||
</div>
|
||||
<%- include('partials/loadingIcon') %>
|
||||
<div data-subreddit="All" class="logs font-mono text-sm">
|
||||
<% logs.forEach(function (logEntry){ %>
|
||||
<%- logEntry %>
|
||||
<% }) %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
<%- include('partials/instanceTabJs') %>
|
||||
<%- include('partials/themeJs') %>
|
||||
<%- include('partials/logSettingsJs') %>
|
||||
<script src="https://cdn.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
window.sort = 'desc';
|
||||
|
||||
const INSTANCE_NAME_LOG_REGEX = /\|(.+?)\|/;
|
||||
const parseALogName = (reg) => {
|
||||
return (val) => {
|
||||
const matches = val.match(reg);
|
||||
if (matches === null) {
|
||||
return undefined;
|
||||
}
|
||||
return matches[1];
|
||||
}
|
||||
}
|
||||
const parseInstanceLogName = parseALogName(INSTANCE_NAME_LOG_REGEX);
|
||||
|
||||
let socket = io({
|
||||
reconnectionAttempts: 5, // bail after 5 attempts
|
||||
});
|
||||
|
||||
const limit = Number.parseInt(document.querySelector(`[data-type="limit"]`).value);
|
||||
|
||||
const instanceURLSP = new URLSearchParams(window.location.search);
|
||||
const instanceSP = instanceURLSP.get('instance');
|
||||
|
||||
socket.on("connect", () => {
|
||||
document.body.classList.add('connected');
|
||||
socket.on("log", data => {
|
||||
const el = document.querySelector(`.sub`);
|
||||
const bot = parseInstanceLogName(data);
|
||||
if(bot === instanceSP) {
|
||||
const logContainer = el.querySelector(`.logs`);
|
||||
let existingLogs;
|
||||
if(window.sort === 'desc') {
|
||||
logContainer.insertAdjacentHTML('afterbegin', data);
|
||||
existingLogs = Array.from(el.querySelectorAll(`.logs .logLine`));
|
||||
logContainer.replaceChildren(...existingLogs.slice(0, limit));
|
||||
} else {
|
||||
logContainer.insertAdjacentHTML('beforeend', data);
|
||||
existingLogs = Array.from(el.querySelectorAll(`.logs .logLine`));
|
||||
const overLimit = limit - existingLogs.length;
|
||||
logContainer.replaceChildren(...existingLogs.slice(overLimit -1, limit));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
30
src/Web/assets/views/partials/botsTab.ejs
Normal file
@@ -0,0 +1,30 @@
|
||||
<div class="space-x-4 py-1 md:px-10 leading-6 font-semibold bg-gray-500 dark:bg-gray-700 text-white">
|
||||
<div class="container mx-auto">
|
||||
<% if(locals.bots !== undefined) { %>
|
||||
<ul id="botTabs" class="inline-flex flex-wrap">
|
||||
<% bots.forEach(function (data){ %>
|
||||
<li class="my-3 px-3 dark:text-white">
|
||||
<span data-bot="<%= data.system.name %>" class="rounded-md py-2 px-3 tabSelectWrapper">
|
||||
<a class="tabSelect font-normal pointer hover:font-bold"
|
||||
data-bot="<%= data.system.name %>">
|
||||
<%= data.system.name %>
|
||||
</a>
|
||||
<% if ((data.system.name === 'All' && isOperator) || data.system.name !== 'All') { %>
|
||||
<span class="inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.system.running ? 'green' : 'red' %>-400 rounded-full"></span>
|
||||
<% } %>
|
||||
</span>
|
||||
</li>
|
||||
<% }) %>
|
||||
<% if(locals.isOperator === true && locals.instanceId !== undefined) { %>
|
||||
<li class="my-3 px-3 dark:text-white">
|
||||
<span class="rounded-md py-2 px-3 border">
|
||||
<a class="font-normal pointer hover:font-bold" href="/auth/helper">
|
||||
Add Bot +
|
||||
</a>
|
||||
</span>
|
||||
</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
5
src/Web/assets/views/partials/footer.ejs
Normal file
@@ -0,0 +1,5 @@
|
||||
<div class="py-3 flex items-center justify-around font-semibold text-white">
|
||||
<div>
|
||||
<a href="https://github.com/FoxxMD/context-mod">ContextMod Web</a> created by /u/FoxxMD
|
||||
</div>
|
||||
</div>
|
||||
@@ -6,9 +6,9 @@
|
||||
integrity="sha512-WvyKyiVHgInX5UQt67447ExtRRZG/8GUijaq1MpqTNYp8wY4/EJOG5bI80sRp/5crDy4Z6bBUydZI2OFV3Vbtg=="
|
||||
crossorigin="anonymous"/>
|
||||
<script src="https://code.iconify.design/1/1.0.4/iconify.min.js"></script>
|
||||
<link rel="stylesheet" href="public/themeToggle.css">
|
||||
<link rel="stylesheet" href="public/app.css">
|
||||
<title><%= title !== undefined ? title : `CM for ${botName}`%></title>
|
||||
<link rel="stylesheet" href="/public/themeToggle.css">
|
||||
<link rel="stylesheet" href="/public/app.css">
|
||||
<title><%= locals.title !== undefined ? title : `${locals.botName !== undefined ? `CM for ${botName}` : 'ContextMod'}`%></title>
|
||||
<!--<title><%# `CM for /u/${botName}`%></title>-->
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
55
src/Web/assets/views/partials/header.ejs
Normal file
@@ -0,0 +1,55 @@
|
||||
<div class="space-x-4 p-6 md:px-10 md:py-6 leading-6 font-semibold bg-gray-800 text-white">
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
<% if(locals.instances !== undefined) { %>
|
||||
<ul class="inline-flex flex-wrap">
|
||||
<% instances.forEach(function (data) { %>
|
||||
<li class="my-3 px-3 dark:text-white">
|
||||
<span data-instance="<%= data.friendly %>" class="has-tooltip instanceSelectWrapper rounded-md py-3 px-3">
|
||||
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black' style="margin-top:3em">
|
||||
<div class="stats">
|
||||
<% if(data.canAccessLocation) { %>
|
||||
<label>Location</label>
|
||||
<span><%= data.normalUrl %></span>
|
||||
<% } %>
|
||||
<label>Online</label>
|
||||
<span><%= data.online %></span>
|
||||
<label>Running</label>
|
||||
<span><%= data.running %></span>
|
||||
<label>Error</label>
|
||||
<span><%= data.error %></span>
|
||||
</div>
|
||||
</span>
|
||||
<span>
|
||||
<a class="instanceSelect hover:font-bold" href="/?instance=<%= data.friendly %>">
|
||||
<%= data.friendly %>
|
||||
</a>
|
||||
<span class="inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.online ? 'green' : 'gray' %>-400 rounded-full"></span>
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="flex items-center flex-end text-sm">
|
||||
<span class="inline-block mr-4">
|
||||
<label style="font-size:2.5px;">
|
||||
<input class='toggle-checkbox' type='checkbox' id="themeToggle" checked></input>
|
||||
<div class='toggle-slot'>
|
||||
<div class='sun-icon-wrapper'>
|
||||
<div class="iconify sun-icon" data-icon="feather-sun" data-inline="false"></div>
|
||||
</div>
|
||||
<div class='toggle-button'></div>
|
||||
<div class='moon-icon-wrapper'>
|
||||
<div class="iconify moon-icon" data-icon="feather-moon" data-inline="false"></div>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
<a href="logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
16
src/Web/assets/views/partials/instanceTabJs.ejs
Normal file
@@ -0,0 +1,16 @@
|
||||
<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>
|
||||
15
src/Web/assets/views/partials/loadingIcon.ejs
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg class="loading" version="1.1" id="L9" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 100 100" xml:space="preserve">
|
||||
<path
|
||||
d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
dur="1s"
|
||||
from="0 50 50"
|
||||
to="360 50 50"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 596 B |
23
src/Web/assets/views/partials/logSettings.ejs
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="flex items-center flex-end space-x-2">
|
||||
<span>
|
||||
<label for="level-select">Level: </label>
|
||||
<select class="logSettingSelect rounded capitalize text-black" data-type="level"
|
||||
id="levels-select">
|
||||
<%- logSettings.levelSelect %>
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
<label for="sort-select">Sort: </label>
|
||||
<select class="logSettingSelect rounded capitalize text-black" data-type="sort"
|
||||
id="sort-select">
|
||||
<%- logSettings.sortSelect %>
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
<label for="limit-select">Limit: </label>
|
||||
<select class="logSettingSelect rounded capitalize text-black" data-type="limit"
|
||||
id="limit-select">
|
||||
<%- logSettings.limitSelect %>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
10
src/Web/assets/views/partials/logSettingsJs.ejs
Normal file
@@ -0,0 +1,10 @@
|
||||
<script>
|
||||
document.querySelectorAll('.logSettingSelect').forEach(el => {
|
||||
el.onchange = (e) => {
|
||||
// this is easier for now
|
||||
fetch(`/logs/settings/update?${e.target.dataset.type}=${e.target.value}`).then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
23
src/Web/assets/views/partials/subredditsTab.ejs
Normal file
@@ -0,0 +1,23 @@
|
||||
<div class="space-x-4 pt-2 md:px-5 leading-6 font-semibold bg-gray-white dark:bg-gray-500 text-white">
|
||||
<div 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">
|
||||
<% botData.subreddits.forEach(function (data){ %>
|
||||
<li class="my-3 pr-3 dark:text-white">
|
||||
<span data-subreddit="<%= data.name %>" class="rounded-md py-2 px-3 tabSelectWrapper">
|
||||
<a class="tabSelect font-normal pointer hover:font-bold"
|
||||
data-subreddit="<%= data.name %>">
|
||||
<%= data.name === 'All' ? 'All Subreddits' : data.name %>
|
||||
</a>
|
||||
<% if ((data.name === 'All' && isOperator) || data.name !== 'All') { %>
|
||||
<span class="inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.indicator %>-400 rounded-full"></span>
|
||||
<% } %>
|
||||
</span>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
<% }) %>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
32
src/Web/assets/views/partials/themeJs.ejs
Normal file
@@ -0,0 +1,32 @@
|
||||
<script>
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (e.target.id === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
})
|
||||
});
|
||||
|
||||
const themeToggle = document.querySelector("#themeToggle");
|
||||
if(themeToggle !== null) {
|
||||
themeToggle.checked = localStorage.getItem('ms-dark') !== 'no';
|
||||
themeToggle.onchange = (e) => {
|
||||
if (e.target.checked === true) {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -2,11 +2,12 @@
|
||||
<div class="container mx-auto">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center flex-grow pr-4">
|
||||
<div class="px-4 width-full relative">
|
||||
<span>
|
||||
<a href="https://github.com/FoxxMD/context-mod">ContextMod</a> for <a href="<%= botLink %>"><%= botName %></a>
|
||||
</span>
|
||||
<span class="inline-block -mb-3 ml-2">
|
||||
<% if(locals.title !== undefined) { %>
|
||||
<%= title %>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="flex items-center flex-end text-sm">
|
||||
<span class="inline-block mr-4">
|
||||
<label style="font-size:2.5px;">
|
||||
<input class='toggle-checkbox' type='checkbox' id="themeToggle" checked></input>
|
||||
<div class='toggle-slot'>
|
||||
@@ -20,12 +21,6 @@
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
<div class="text-small absolute pl-4">
|
||||
Operated by <%= operatorDisplay %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center flex-end text-sm">
|
||||
<a href="logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,36 +3,16 @@
|
||||
<body class="">
|
||||
<script>localStorage.getItem('ms-dark') === 'no' ? document.body.classList.remove('dark') : document.body.classList.add('dark')</script>
|
||||
<div class="min-w-screen min-h-screen bg-gray-100 bg-gray-100 dark:bg-gray-800 font-sans">
|
||||
<%- include('partials/authTitle') %>
|
||||
<div class="space-x-4 py-1 md:px-10 leading-6 font-semibold bg-gray-500 dark:bg-gray-700 text-white">
|
||||
<div class="container mx-auto">
|
||||
<ul id="tabs" class="inline-flex flex-wrap">
|
||||
<% subreddits.forEach(function (data){ %>
|
||||
<li class="my-3 px-3 dark:text-white">
|
||||
<span data-subreddit="<%= data.name %>" class="rounded-md py-2 px-3 tabSelectWrapper">
|
||||
<a class="tabSelect font-normal pointer hover:font-bold"
|
||||
data-subreddit="<%= data.name %>">
|
||||
<%= data.name %>
|
||||
</a>
|
||||
<% if ((data.name === 'All' && isOperator) || data.name !== 'All') { %>
|
||||
<span class="inline-block mb-0.5 ml-0.5 w-2 h-2 bg-<%= data.indicator %>-400 rounded-full"></span>
|
||||
<% } %>
|
||||
</span>
|
||||
</li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/header') %>
|
||||
<%- include('partials/botsTab') %>
|
||||
<div class="container mx-auto">
|
||||
<%- include('partials/subredditsTab') %>
|
||||
<div class="grid">
|
||||
<div class="bg-white dark:bg-gray-500 dark:text-white">
|
||||
<div class="pb-6 pt-3 md:px-7">
|
||||
<!-- <div class="flex items-center justify-around">-->
|
||||
<!-- -->
|
||||
|
||||
<!-- </div>-->
|
||||
<% subreddits.forEach(function (data){ %>
|
||||
<div class="sub" data-subreddit="<%= data.name %>">
|
||||
<div class="pb-6 md:px-7">
|
||||
<% bots.forEach(function (bot){ %>
|
||||
<% 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 dark:bg-gray-500 dark:text-white">
|
||||
<div class="space-x-4 px-4 p-2 leading-2 font-semibold bg-gray-300 dark:bg-gray-700 dark:text-white">
|
||||
@@ -181,13 +161,17 @@
|
||||
<span><%= data.delayBy %></span>
|
||||
<% } %>
|
||||
<% if (data.name === 'All') { %>
|
||||
<label>Status</label>
|
||||
<span class="font-semibold"><%= bot.system.running ? 'ONLINE' : 'OFFLINE' %></span>
|
||||
<label>Account</label>
|
||||
<span><a href="https://reddit.com/<%= bot.system.account %>"><%= bot.system.account %></a></span>
|
||||
<label>Uptime</label>
|
||||
<span class="has-tooltip">
|
||||
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
|
||||
<%= system.startedAt %>
|
||||
<%= bot.system.startedAt %>
|
||||
</span>
|
||||
<span>
|
||||
<span id="startedAtHuman"><%= system.startedAtHuman %></span>
|
||||
<span id="startedAtHuman"><%= bot.system.startedAtHuman %></span>
|
||||
</span>
|
||||
</span>
|
||||
<label>Heartbeat Interval</label>
|
||||
@@ -234,10 +218,12 @@
|
||||
</span>
|
||||
</label>
|
||||
<span><%= `${data.runningActivities} Processing / ${data.queuedActivities} Queued` %></span>
|
||||
<label>Operated By</label>
|
||||
<span><%= operatorDisplay %></span>
|
||||
<% if (data.name === 'All' && isOperator) { %>
|
||||
<label>Operators</label>
|
||||
<span><%= operators %></span>
|
||||
<% } %>
|
||||
<% } else %>
|
||||
</div>
|
||||
<% if (data.name !== 'All') { %>
|
||||
<ul class="list-disc list-inside mt-4">
|
||||
@@ -324,7 +310,7 @@
|
||||
<span>
|
||||
<a style="display: inline"
|
||||
href="<%= data.wikiHref %>"><%= data.wikiLocation %></a> | <a style="display: inline" target="_blank"
|
||||
href="/config?subreddit=<%= data.name %>">View</a>
|
||||
href="/config?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>">View</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -593,45 +579,9 @@
|
||||
</span>
|
||||
<a class="runCheck" data-subreddit="<%= data.name %>" href="">Run</a>
|
||||
</div>
|
||||
<div class="flex items-center flex-end space-x-2">
|
||||
<span>
|
||||
<label for="level-select">Level: </label>
|
||||
<select class="logSettingSelect rounded capitalize text-black" data-type="level"
|
||||
id="levels-select">
|
||||
<%- logSettings.levelSelect %>
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
<label for="sort-select">Sort: </label>
|
||||
<select class="logSettingSelect rounded capitalize text-black" data-type="sort"
|
||||
id="sort-select">
|
||||
<%- logSettings.sortSelect %>
|
||||
</select>
|
||||
</span>
|
||||
<span>
|
||||
<label for="limit-select">Limit: </label>
|
||||
<select class="logSettingSelect rounded capitalize text-black" data-type="limit"
|
||||
id="limit-select">
|
||||
<%- logSettings.limitSelect %>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
<%- include('partials/logSettings') %>
|
||||
</div>
|
||||
<svg class="loading" version="1.1" id="L9" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 100 100" xml:space="preserve">
|
||||
<path
|
||||
d="M73,50c0-12.7-10.3-23-23-23S27,37.3,27,50 M30.9,50c0-10.5,8.5-19.1,19.1-19.1S69.1,39.5,69.1,50">
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="rotate"
|
||||
dur="1s"
|
||||
from="0 50 50"
|
||||
to="360 50 50"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
</svg>
|
||||
<%- include('partials/loadingIcon') %>
|
||||
<div data-subreddit="<%= data.name %>" class="logs font-mono text-sm">
|
||||
<% data.logs.forEach(function (logEntry){ %>
|
||||
<%- logEntry %>
|
||||
@@ -639,6 +589,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<% }) %>
|
||||
<% }) %>
|
||||
</div>
|
||||
<!--<div class="w-full flex-auto flex min-h-0 overflow-auto">
|
||||
<div class="w-full relative flex-auto">
|
||||
@@ -647,69 +598,25 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%- include('partials/footer') %>
|
||||
</div>
|
||||
<%- include('partials/instanceTabJs') %>
|
||||
<%- include('partials/themeJs') %>
|
||||
<%- include('partials/logSettingsJs') %>
|
||||
<script>
|
||||
/* const appendUser = causedBy => causedBy === 'system' ? '' : ' (user)';
|
||||
const initialData =
|
||||
const updateOverview = (sub, data) => {
|
||||
|
||||
}*/
|
||||
window.sort = 'desc';
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault();
|
||||
if (e.target.id === 'dark') {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
document.querySelectorAll('.theme').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
})
|
||||
})
|
||||
|
||||
document.querySelector("#themeToggle").checked = localStorage.getItem('ms-dark') !== 'no';
|
||||
document.querySelector("#themeToggle").onchange = (e) => {
|
||||
if (e.target.checked === true) {
|
||||
document.body.classList.add('dark');
|
||||
localStorage.setItem('ms-dark', 'yes');
|
||||
} else {
|
||||
document.body.classList.remove('dark');
|
||||
localStorage.setItem('ms-dark', 'no');
|
||||
}
|
||||
}
|
||||
// if (localStorage.getItem('ms-dark') === 'no') {
|
||||
// document.querySelector('#light.theme').classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
// document.body.classList.remove('dark')
|
||||
// } else {
|
||||
// document.querySelector('#dark.theme').classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
// }
|
||||
|
||||
document.querySelectorAll('.logSettingSelect').forEach(el => {
|
||||
el.onchange = (e) => {
|
||||
action = e.target.dataset.type;
|
||||
value = e.target.value;
|
||||
fetch(`logs/settings/update?${action}=${value}`);
|
||||
document.querySelectorAll(`#${e.target.id}.logSettingSelect option`).forEach(el => {
|
||||
el.classList.remove('font-bold');
|
||||
});
|
||||
document.querySelector(`#${e.target.id}.logSettingSelect option[data-value="${e.target.value}"]`).classList.add('font-bold');
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('.action').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
e.preventDefault()
|
||||
e.preventDefault();
|
||||
const subSection = e.target.closest('div.sub');
|
||||
action = e.target.dataset.action;
|
||||
subreddit = e.target.dataset.subreddit;
|
||||
subreddit = subSection.dataset.subreddit;
|
||||
bot = subSection.dataset.bot;
|
||||
type = e.target.dataset.type;
|
||||
force = e.target.dataset.force === 'true';
|
||||
|
||||
fetch(`action?action=${action}&subreddit=${subreddit}&force=${force}&type=${type}`);
|
||||
fetch(`/api/action?instance=<%= instanceId %>&bot=${bot}&action=${action}&subreddit=${subreddit}&force=${force}&type=${type}`);
|
||||
});
|
||||
})
|
||||
|
||||
@@ -720,11 +627,12 @@
|
||||
const urlInput = document.querySelector(`[data-subreddit="${subreddit}"].checkUrl`);
|
||||
const dryRunCheck = document.querySelector(`[data-subreddit="${subreddit}"].dryrunCheck`);
|
||||
|
||||
|
||||
const subSection = e.target.closest('div.sub');
|
||||
bot = subSection.dataset.bot;
|
||||
const url = urlInput.value;
|
||||
const dryRun = dryRunCheck.checked ? 1 : 0;
|
||||
|
||||
const fetchUrl = `check?url=${url}&dryRun=${dryRun}&subreddit=${subreddit}`;
|
||||
const fetchUrl = `/api/check?instanceId=<%= instanceId %>%bot=${bot}&url=${url}&dryRun=${dryRun}&subreddit=${subreddit}`;
|
||||
fetch(fetchUrl);
|
||||
|
||||
urlInput.value = '';
|
||||
@@ -751,8 +659,56 @@
|
||||
});
|
||||
})
|
||||
|
||||
document.querySelectorAll('[data-bot].tabSelect').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
const bot = e.target.dataset.bot;
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.tabSelect').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
document.querySelectorAll('[data-subreddit].sub').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
const firstSub = document.querySelectorAll(`[data-bot="${bot}"].sub`)[0];
|
||||
firstSub.classList.add('active');
|
||||
|
||||
const firstSubTab = document.querySelector(`ul[data-bot="${bot}"] [data-subreddit="${firstSub.dataset.subreddit}"].tabSelect`);
|
||||
firstSubTab.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
const firstSubWrapper = firstSubTab.closest('.tabSelectWrapper');
|
||||
//document.querySelector(`[data-subreddit="${subreddit}"][data-bot="${bot}"].sub`).classList.add('active');
|
||||
|
||||
document.querySelectorAll('.tabSelectWrapper').forEach(el => {
|
||||
el.classList.remove('border-2');
|
||||
el.classList.add('border');
|
||||
})
|
||||
|
||||
firstSubWrapper.classList.remove('border');
|
||||
firstSubWrapper.classList.add('border-2');
|
||||
|
||||
document.querySelectorAll('[data-bot].subreddit.nestedTabs').forEach(el => {
|
||||
el.classList.remove('active');
|
||||
})
|
||||
document.querySelector(`[data-bot="${bot}"].subreddit.nestedTabs`).classList.add('active');
|
||||
|
||||
const wrapper = e.target.closest('.tabSelectWrapper');//document.querySelector(`[data-subreddit="${subreddit}"].tabSelectWrapper`);
|
||||
wrapper.classList.remove('border');
|
||||
wrapper.classList.add('border-2');
|
||||
|
||||
if ('URLSearchParams' in window) {
|
||||
var searchParams = new URLSearchParams(window.location.search)
|
||||
searchParams.set("bot", bot);
|
||||
searchParams.set("sub", firstSub.dataset.subreddit);
|
||||
var newRelativePathQuery = window.location.pathname + '?' + searchParams.toString();
|
||||
history.pushState(null, '', newRelativePathQuery);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-subreddit].tabSelect').forEach(el => {
|
||||
el.addEventListener('click', e => {
|
||||
const bot = e.target.closest('ul').dataset.bot;
|
||||
const subreddit = e.target.dataset.subreddit;
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('[data-subreddit].tabSelect').forEach(el => {
|
||||
el.classList.remove('font-bold', 'no-underline', 'pointer-events-none');
|
||||
@@ -761,14 +717,14 @@
|
||||
el.classList.remove('active');
|
||||
});
|
||||
e.target.classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
document.querySelector(`[data-subreddit="${e.target.dataset.subreddit}"].sub`).classList.add('active');
|
||||
document.querySelector(`[data-subreddit="${subreddit}"][data-bot="${bot}"].sub`).classList.add('active');
|
||||
|
||||
document.querySelectorAll('.tabSelectWrapper').forEach(el => {
|
||||
document.querySelectorAll('[data-subreddit].tabSelectWrapper').forEach(el => {
|
||||
el.classList.remove('border-2');
|
||||
el.classList.add('border');
|
||||
})
|
||||
|
||||
const wrapper = document.querySelector(`[data-subreddit="${e.target.dataset.subreddit}"].tabSelectWrapper`);
|
||||
const wrapper = e.target.closest('.tabSelectWrapper');//document.querySelector(`[data-subreddit="${subreddit}"].tabSelectWrapper`);
|
||||
wrapper.classList.remove('border');
|
||||
wrapper.classList.add('border-2');
|
||||
|
||||
@@ -779,72 +735,131 @@
|
||||
history.pushState(null, '', newRelativePathQuery);
|
||||
}
|
||||
});
|
||||
})
|
||||
document.querySelector('[data-subreddit="<%= show %>"].tabSelect').classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
});
|
||||
|
||||
|
||||
var searchParams = new URLSearchParams(window.location.search);
|
||||
const shownSub = searchParams.get('sub') || 'All'
|
||||
let shownBot = searchParams.get('bot');
|
||||
if(shownBot === null) {
|
||||
// show the first bot listed if none is specified
|
||||
const firstBot = document.querySelector('.tabSelectWrapper[data-bot]');
|
||||
if(firstBot !== null) {
|
||||
shownBot = firstBot.dataset.bot;
|
||||
searchParams.set('bot', shownBot);
|
||||
var newRelativePathQuery = window.location.pathname + '?' + searchParams.toString();
|
||||
history.pushState(null, '', newRelativePathQuery);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector(`[data-bot="${shownBot}"].tabSelect`).classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
document.querySelector(`ul[data-bot="${shownBot}"] [data-subreddit="${shownSub}"].tabSelect`).classList.add('font-bold', 'no-underline', 'pointer-events-none');
|
||||
document.querySelectorAll('.tabSelectWrapper').forEach(el => el.classList.add('border'));
|
||||
document.querySelector('[data-subreddit="<%= show %>"].sub').classList.add('active')
|
||||
const wrapper = document.querySelector(`[data-subreddit="<%= show %>"].tabSelectWrapper`);
|
||||
document.querySelector(`[data-bot="${shownBot}"][data-subreddit="${shownSub}"].sub`).classList.add('active');
|
||||
const subWrapper = document.querySelector(`ul[data-bot="${shownBot}"] [data-subreddit="${shownSub}"].tabSelectWrapper`);
|
||||
subWrapper.classList.remove('border');
|
||||
subWrapper.classList.add('border-2');
|
||||
const wrapper = document.querySelector(`[data-bot="${shownBot}"].tabSelectWrapper`);
|
||||
wrapper.classList.remove('border');
|
||||
wrapper.classList.add('border-2');
|
||||
|
||||
document.querySelector(`[data-bot="${shownBot}"].subreddit.nestedTabs`).classList.add('active');
|
||||
|
||||
document.querySelectorAll('.stats.reloadStats').forEach(el => el.classList.add('hidden'));
|
||||
document.querySelectorAll('.allStatsToggle').forEach(el => el.classList.add('font-bold', 'no-underline', 'pointer-events-none'));
|
||||
</script>
|
||||
|
||||
<script src="https://cdn.socket.io/3.1.3/socket.io.min.js"
|
||||
integrity="sha384-cPwlPLvBTa3sKAgddT6krw0cJat7egBga3DJepJyrLl4Q9/5WLra3rrnMcyTyOnh"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.socket.io/4.1.2/socket.io.min.js" integrity="sha384-toS6mmwu70G0fw54EGlWWeA4z3dyJ+dlXBtSURSKN4vyRFOcxd3Bzjj/AoOwY+Rg" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
const SUBREDDIT_NAME_LOG_REGEX = /{(.+?)}/;
|
||||
const parseSubredditLogName = (val) => {
|
||||
const matches = val.match(SUBREDDIT_NAME_LOG_REGEX);
|
||||
if (matches === null) {
|
||||
return undefined;
|
||||
const BOT_NAME_LOG_REGEX = /~(.+?)~/;
|
||||
const parseALogName = (reg) => {
|
||||
return (val) => {
|
||||
const matches = val.match(reg);
|
||||
if (matches === null) {
|
||||
return undefined;
|
||||
}
|
||||
return matches[1];
|
||||
}
|
||||
return matches[1];
|
||||
}
|
||||
const parseSubredditLogName = parseALogName(SUBREDDIT_NAME_LOG_REGEX);
|
||||
const parseBotLogName = parseALogName(BOT_NAME_LOG_REGEX);
|
||||
|
||||
let socket = io({
|
||||
reconnectionAttempts: 5, // bail after 5 attempts
|
||||
});
|
||||
|
||||
const newBufferedLogs = () => new Map([["All", []]])
|
||||
// get all bots
|
||||
let bots = [];
|
||||
document.querySelectorAll(`[data-bot].sub`).forEach((el) => {
|
||||
bots.push(el.dataset.bot);
|
||||
})
|
||||
const newBufferedLogs = () => new Map([["All", []]]);
|
||||
const newBufferedBot = () => new Map([["All", []], ...bots.map(x => ([x, newBufferedLogs()]))]);
|
||||
|
||||
|
||||
let bufferedLogs = newBufferedLogs();
|
||||
let bufferedBot = newBufferedBot();
|
||||
let lastFlush;
|
||||
let bufferTimeout;
|
||||
|
||||
socket.on("connect", () => {
|
||||
document.body.classList.add('connected')
|
||||
socket.on("log", data => {
|
||||
bufferedLogs.set('All', bufferedLogs.get('All').concat(data));
|
||||
const sub = parseSubredditLogName(data);
|
||||
if (sub !== undefined) {
|
||||
bufferedLogs.set(sub, (bufferedLogs.get(sub) || []).concat(data));
|
||||
bufferedBot.set('All', bufferedBot.get('All').concat(data));
|
||||
|
||||
const bot = parseBotLogName(data);
|
||||
if(bot !== undefined) {
|
||||
const buffBot = bufferedBot.get(bot) || newBufferedLogs();
|
||||
buffBot.set('All', buffBot.get('All').concat(data));
|
||||
const sub = parseSubredditLogName(data);
|
||||
if (sub !== undefined) {
|
||||
buffBot.set(sub, (buffBot.get(sub) || []).concat(data));
|
||||
}
|
||||
bufferedBot.set(bot, buffBot);
|
||||
} else {
|
||||
bufferedBot.forEach((logs, botName) => {
|
||||
if(botName === 'All') {
|
||||
return;
|
||||
}
|
||||
logs.set('All', logs.get('All').concat(data));
|
||||
bufferedBot.set(botName, logs);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const flushLogs = () => {
|
||||
bufferedLogs.forEach((logs, subKey) => {
|
||||
const limit = Number.parseInt(document.querySelector(`[data-subreddit="${subKey}"] [data-type="limit"]`).value);
|
||||
const logContainer = document.querySelector(`[data-subreddit="${subKey}"].logs`);
|
||||
let existingLogs;
|
||||
if(window.sort === 'desc') {
|
||||
logs.forEach((l) => {
|
||||
logContainer.insertAdjacentHTML('afterbegin', l);
|
||||
})
|
||||
existingLogs = Array.from(document.querySelectorAll(`[data-subreddit="${subKey}"].logs .logLine`));
|
||||
logContainer.replaceChildren(...existingLogs.slice(0, limit));
|
||||
} else {
|
||||
logs.forEach((l) => {
|
||||
logContainer.insertAdjacentHTML('beforeend', l);
|
||||
existingLogs = Array.from(document.querySelectorAll(`[data-subreddit="${subKey}"].logs .logLine`));
|
||||
const overLimit = limit - existingLogs.length;
|
||||
logContainer.replaceChildren(...existingLogs.slice(overLimit -1, limit));
|
||||
})
|
||||
bufferedBot.forEach((subLogs, botName) => {
|
||||
if(botName === 'All') {
|
||||
return;
|
||||
}
|
||||
subLogs.forEach((logs, subKey) => {
|
||||
// check sub exists -- may be a web log
|
||||
const el = document.querySelector(`[data-subreddit="${subKey}"][data-bot="${botName}"].sub`);
|
||||
if(null !== el) {
|
||||
const limit = Number.parseInt(document.querySelector(`[data-subreddit="${subKey}"] [data-type="limit"]`).value);
|
||||
const logContainer = el.querySelector(`.logs`);
|
||||
let existingLogs;
|
||||
if(window.sort === 'desc') {
|
||||
logs.forEach((l) => {
|
||||
logContainer.insertAdjacentHTML('afterbegin', l);
|
||||
})
|
||||
existingLogs = Array.from(el.querySelectorAll(`.logs .logLine`));
|
||||
logContainer.replaceChildren(...existingLogs.slice(0, limit));
|
||||
} else {
|
||||
logs.forEach((l) => {
|
||||
logContainer.insertAdjacentHTML('beforeend', l);
|
||||
existingLogs = Array.from(el.querySelectorAll(`.logs .logLine`));
|
||||
const overLimit = limit - existingLogs.length;
|
||||
logContainer.replaceChildren(...existingLogs.slice(overLimit -1, limit));
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
lastFlush = Date.now();
|
||||
bufferedLogs = newBufferedLogs();
|
||||
bufferedBot = newBufferedBot();
|
||||
//bufferedLogs = newBufferedLogs();
|
||||
//console.log('Flushed Logs');
|
||||
}
|
||||
|
||||
@@ -859,16 +874,23 @@
|
||||
bufferTimeout = setTimeout(() => {flushLogs();}, 1000);
|
||||
}
|
||||
});
|
||||
socket.on("webLog", data => {
|
||||
console.log(data);
|
||||
});
|
||||
socket.on("logClear", data => {
|
||||
data.forEach((obj) => {
|
||||
const n = obj.name === 'all' ? 'All' : obj.name;
|
||||
document.querySelector(`[data-subreddit="${n}"].logs`).innerHTML = obj.logs;
|
||||
})
|
||||
});
|
||||
socket.on('opStats', (data) => {
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
document.querySelector(`#${k}`).innerHTML = v;
|
||||
socket.on('opStats', (resp) => {
|
||||
for(const b of resp) {
|
||||
const {name, data} = b;
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
document.querySelector(`[data-bot="${name}"].sub #${k}`).innerHTML = v;
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
24
src/Web/interfaces.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { URL } from "url";
|
||||
import {BotConnection} from "../Common/interfaces";
|
||||
|
||||
export interface BotInstance {
|
||||
botName: string
|
||||
botLink: string
|
||||
error?: string
|
||||
subreddits: string[]
|
||||
nanny?: string
|
||||
running: boolean
|
||||
}
|
||||
|
||||
export interface CMInstance extends BotConnection {
|
||||
friendly: string
|
||||
operators: string[]
|
||||
operatorDisplay: string
|
||||
url: URL,
|
||||
normalUrl: string,
|
||||
lastCheck: number
|
||||
online: boolean
|
||||
subreddits: string[]
|
||||
bots: BotInstance[]
|
||||
error?: string
|
||||
}
|
||||
25
src/Web/types/express/index.d.ts
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
import {App} from "../../../App";
|
||||
import Bot from "../../../Bot";
|
||||
import {BotInstance, CMInstance} from "../../interfaces";
|
||||
|
||||
declare global {
|
||||
declare namespace Express {
|
||||
interface Request {
|
||||
botApp: App;
|
||||
token?: string,
|
||||
instance?: CMInstance,
|
||||
bot?: BotInstance,
|
||||
serverBot: Bot,
|
||||
}
|
||||
interface User {
|
||||
name: string
|
||||
subreddits: string[]
|
||||
machine?: boolean
|
||||
isOperator?: boolean
|
||||
realManagers?: string[]
|
||||
moderatedManagers?: string[]
|
||||
realBots?: string[]
|
||||
moderatedBots?: string[]
|
||||
}
|
||||
}
|
||||
}
|
||||
136
src/index.ts
@@ -17,13 +17,14 @@ import {
|
||||
operatorConfig
|
||||
} from "./Utils/CommandConfig";
|
||||
import {App} from "./App";
|
||||
import createWebServer from './Server/server';
|
||||
import createHelperServer from './Server/helper';
|
||||
import apiServer from './Web/Server/server';
|
||||
import clientServer from './Web/Client';
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {COMMENT_URL_ID, parseLinkIdentifier, SUBMISSION_URL_ID} from "./util";
|
||||
import LoggedError from "./Utils/LoggedError";
|
||||
import {getLogger} from "./Utils/loggerFactory";
|
||||
import {buildOperatorConfigWithDefaults, parseOperatorConfigFromSources} from "./ConfigBuilder";
|
||||
import {getLogger} from "./Utils/loggerFactory";
|
||||
import Bot from "./Bot";
|
||||
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(dduration);
|
||||
@@ -42,57 +43,32 @@ const program = new Command();
|
||||
|
||||
(async function () {
|
||||
let app: App;
|
||||
let errorReason: string | undefined;
|
||||
process.on('SIGTERM', async () => {
|
||||
if(app !== undefined) {
|
||||
await app.onTerminate(errorReason);
|
||||
}
|
||||
process.exit(errorReason === undefined ? 0 : 1);
|
||||
});
|
||||
// let errorReason: string | undefined;
|
||||
// process.on('SIGTERM', async () => {
|
||||
// if(app !== undefined) {
|
||||
// await app.onTerminate(errorReason);
|
||||
// }
|
||||
// process.exit(errorReason === undefined ? 0 : 1);
|
||||
// });
|
||||
try {
|
||||
|
||||
let runCommand = program
|
||||
.command('run')
|
||||
.addArgument(new Argument('[interface]', 'Which interface to start the bot with').choices(['web', 'cli']).default(undefined, 'process.env.WEB || true'))
|
||||
.addArgument(new Argument('[interface]', 'Which interface to start the bot with').choices(['client', 'server', 'all']).default(undefined, 'process.env.MODE || all'))
|
||||
.description('Monitor new activities from configured subreddits.')
|
||||
.allowUnknownOption();
|
||||
runCommand = addOptions(runCommand, getUniversalWebOptions());
|
||||
runCommand.action(async (interfaceVal, opts) => {
|
||||
const config = buildOperatorConfigWithDefaults(await parseOperatorConfigFromSources({...opts, web: interfaceVal !== undefined ? interfaceVal === 'web': undefined}));
|
||||
const config = buildOperatorConfigWithDefaults(await parseOperatorConfigFromSources({...opts, mode: interfaceVal}));
|
||||
const {
|
||||
credentials: {
|
||||
redirectUri,
|
||||
clientId,
|
||||
clientSecret,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
},
|
||||
web: {
|
||||
enabled: web,
|
||||
},
|
||||
logging,
|
||||
mode,
|
||||
} = config;
|
||||
const logger = getLogger(logging, 'init');
|
||||
const hasClient = clientId !== undefined && clientSecret !== undefined;
|
||||
const hasNoTokens = accessToken === undefined && refreshToken === undefined;
|
||||
try {
|
||||
if (web) {
|
||||
if (hasClient && hasNoTokens) {
|
||||
// run web helper
|
||||
const server = createHelperServer(config);
|
||||
await server;
|
||||
} else {
|
||||
if (redirectUri === undefined) {
|
||||
logger.warn(`No 'redirectUri' found in arg/env. Bot will still run but web interface will not be accessible.`);
|
||||
}
|
||||
const [server, bot] = createWebServer(config);
|
||||
app = bot;
|
||||
await server();
|
||||
}
|
||||
} else {
|
||||
app = new App(config);
|
||||
await app.buildManagers();
|
||||
await app.runManagers();
|
||||
if(mode === 'all' || mode === 'client') {
|
||||
await clientServer(config);
|
||||
}
|
||||
if(mode === 'all' || mode === 'server') {
|
||||
await apiServer(config);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err;
|
||||
@@ -100,16 +76,17 @@ const program = new Command();
|
||||
});
|
||||
|
||||
let checkCommand = program
|
||||
.command('check <activityIdentifier> [type]')
|
||||
.command('check <activityIdentifier> [type] [bot]')
|
||||
.allowUnknownOption()
|
||||
.description('Run check(s) on a specific activity', {
|
||||
activityIdentifier: 'Either a permalink URL or the ID of the Comment or Submission',
|
||||
type: `If activityIdentifier is not a permalink URL then the type of activity ('comment' or 'submission'). May also specify 'submission' type when using a permalink to a comment to get the Submission`,
|
||||
bot: 'Specify the bot to try with using `bot.name` (from config) -- otherwise all bots will be built before the bot to be used can be determined'
|
||||
});
|
||||
checkCommand = addOptions(checkCommand, getUniversalCLIOptions());
|
||||
checkCommand
|
||||
.addOption(checks)
|
||||
.action(async (activityIdentifier, type, commandOptions = {}) => {
|
||||
.action(async (activityIdentifier, type, botVal, commandOptions = {}) => {
|
||||
const config = buildOperatorConfigWithDefaults(await parseOperatorConfigFromSources(commandOptions));
|
||||
const {checks = []} = commandOptions;
|
||||
app = new App(config);
|
||||
@@ -154,37 +131,63 @@ const program = new Command();
|
||||
// @ts-ignore
|
||||
const activity = await a.fetch();
|
||||
const sub = await activity.subreddit.display_name;
|
||||
await app.buildManagers([sub]);
|
||||
if (app.subManagers.length > 0) {
|
||||
const manager = app.subManagers.find(x => x.subreddit.display_name === sub) as Manager;
|
||||
await manager.runChecks(type === 'comment' ? 'Comment' : 'Submission', activity, {checkNames: checks});
|
||||
const logger = winston.loggers.get('app');
|
||||
let bots: Bot[] = [];
|
||||
if(botVal !== undefined) {
|
||||
const bot = app.bots.find(x => x.botName === botVal);
|
||||
if(bot === undefined) {
|
||||
logger.error(`No bot named "${botVal} found"`);
|
||||
} else {
|
||||
bots = [bot];
|
||||
}
|
||||
} else {
|
||||
bots = app.bots;
|
||||
}
|
||||
for(const b of bots) {
|
||||
await b.buildManagers([sub]);
|
||||
if(b.subManagers.length > 0) {
|
||||
const manager = b.subManagers[0];
|
||||
await manager.runChecks(type === 'comment' ? 'Comment' : 'Submission', activity, {checkNames: checks});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let unmodCommand = program.command('unmoderated <subreddits...>')
|
||||
.description('Run checks on all unmoderated activity in the modqueue', {
|
||||
subreddits: 'The list of subreddits to run on. If not specified will run on all subreddits the account has moderation access to.'
|
||||
subreddits: 'The list of subreddits to run on. If not specified will run on all subreddits the account has moderation access to.',
|
||||
bot: 'Specify the bot to try with using `bot.name` (from config) -- otherwise all bots will be built before the bot to be used can be determined'
|
||||
})
|
||||
.allowUnknownOption();
|
||||
unmodCommand = addOptions(unmodCommand, getUniversalCLIOptions());
|
||||
unmodCommand
|
||||
.addOption(checks)
|
||||
.action(async (subreddits = [], opts = {}) => {
|
||||
.action(async (subreddits = [], botVal, opts = {}) => {
|
||||
const config = buildOperatorConfigWithDefaults(await parseOperatorConfigFromSources(opts));
|
||||
const {checks = []} = opts;
|
||||
const {subreddits: {names}} = config;
|
||||
app = new App(config);
|
||||
|
||||
await app.buildManagers(names);
|
||||
|
||||
for (const manager of app.subManagers) {
|
||||
const activities = await manager.subreddit.getUnmoderated();
|
||||
for (const a of activities.reverse()) {
|
||||
manager.queue.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
options: {checkNames: checks}
|
||||
});
|
||||
const logger = winston.loggers.get('app');
|
||||
let bots: Bot[] = [];
|
||||
if(botVal !== undefined) {
|
||||
const bot = app.bots.find(x => x.botName === botVal);
|
||||
if(bot === undefined) {
|
||||
logger.error(`No bot named "${botVal} found"`);
|
||||
} else {
|
||||
bots = [bot];
|
||||
}
|
||||
} else {
|
||||
bots = app.bots;
|
||||
}
|
||||
for(const b of bots) {
|
||||
await b.buildManagers(subreddits);
|
||||
for(const manager of b.subManagers) {
|
||||
const activities = await manager.subreddit.getUnmoderated();
|
||||
for (const a of activities.reverse()) {
|
||||
manager.queue.push({
|
||||
checkType: a instanceof Submission ? 'Submission' : 'Comment',
|
||||
activity: a,
|
||||
options: {checkNames: checks}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -193,16 +196,15 @@ const program = new Command();
|
||||
|
||||
} catch (err) {
|
||||
if (!err.logged && !(err instanceof LoggedError)) {
|
||||
const logger = winston.loggers.get('default');
|
||||
const logger = winston.loggers.get('app');
|
||||
if (err.name === 'StatusCodeError' && err.response !== undefined) {
|
||||
const authHeader = err.response.headers['www-authenticate'];
|
||||
if (authHeader !== undefined && authHeader.includes('insufficient_scope')) {
|
||||
logger.error('Reddit responded with a 403 insufficient_scope, did you choose the correct scopes?');
|
||||
}
|
||||
}
|
||||
console.log(err);
|
||||
logger.error(err);
|
||||
}
|
||||
errorReason = `Application crashed due to an uncaught error: ${err.message}`;
|
||||
process.kill(process.pid, 'SIGTERM');
|
||||
}
|
||||
}());
|
||||
|
||||
141
src/util.ts
@@ -12,7 +12,7 @@ import {inflateSync, deflateSync} from "zlib";
|
||||
import {
|
||||
ActivityWindowCriteria, CacheOptions, CacheProvider,
|
||||
DurationComparison,
|
||||
GenericComparison, NamedGroup,
|
||||
GenericComparison, LogInfo, NamedGroup,
|
||||
PollingOptionsStrong, RegExResult, ResourceStats,
|
||||
StringOperator
|
||||
} from "./Common/interfaces";
|
||||
@@ -25,7 +25,9 @@ import {cacheOptDefaults} from "./Common/defaults";
|
||||
import cacheManager, {Cache} from "cache-manager";
|
||||
import redisStore from "cache-manager-redis-store";
|
||||
import crypto from "crypto";
|
||||
import Autolinker from 'autolinker';
|
||||
import {create as createMemoryStore} from './Utils/memoryStore';
|
||||
import {MESSAGE} from "triple-beam";
|
||||
|
||||
const {format} = winston;
|
||||
const {combine, printf, timestamp, label, splat, errors} = format;
|
||||
@@ -75,19 +77,21 @@ export const FAIL = '✘';
|
||||
|
||||
export const truncateStringToLength = (length: number, truncStr = '...') => (str: string) => str.length > length ? `${str.slice(0, length - truncStr.length - 1)}${truncStr}` : str;
|
||||
|
||||
export const defaultFormat = printf(({
|
||||
level,
|
||||
message,
|
||||
labels = ['App'],
|
||||
subreddit,
|
||||
leaf,
|
||||
itemId,
|
||||
timestamp,
|
||||
// @ts-ignore
|
||||
[SPLAT]: splatObj,
|
||||
stack,
|
||||
...rest
|
||||
}) => {
|
||||
export const defaultFormat = (defaultLabel = 'App') => printf(({
|
||||
level,
|
||||
message,
|
||||
labels = [defaultLabel],
|
||||
subreddit,
|
||||
bot,
|
||||
instance,
|
||||
leaf,
|
||||
itemId,
|
||||
timestamp,
|
||||
// @ts-ignore
|
||||
[SPLAT]: splatObj,
|
||||
stack,
|
||||
...rest
|
||||
}) => {
|
||||
let stringifyValue = splatObj !== undefined ? jsonStringify(splatObj) : '';
|
||||
let msg = message;
|
||||
let stackMsg = '';
|
||||
@@ -99,7 +103,7 @@ export const defaultFormat = printf(({
|
||||
.map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
|
||||
.join('\n'); // rejoin with newline to preserve formatting
|
||||
stackMsg = `\n${cleanedStack}`;
|
||||
if(msg === undefined || msg === null || typeof message === 'object') {
|
||||
if (msg === undefined || msg === null || typeof message === 'object') {
|
||||
msg = stackTop;
|
||||
} else {
|
||||
stackMsg = `\n${stackTop}${stackMsg}`
|
||||
@@ -112,23 +116,23 @@ export const defaultFormat = printf(({
|
||||
}
|
||||
const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`;
|
||||
|
||||
return `${timestamp} ${level.padEnd(7)}: ${subreddit !== undefined ? `{${subreddit}} ` : ''}${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
return `${timestamp} ${level.padEnd(7)}: ${instance !== undefined ? `|${instance}| ` : ''}${bot !== undefined ? `~${bot}~ ` : ''}${subreddit !== undefined ? `{${subreddit}} ` : ''}${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
||||
});
|
||||
|
||||
|
||||
export const labelledFormat = (labelName = 'App') => {
|
||||
const l = label({label: labelName, message: false});
|
||||
//const l = label({label: labelName, message: false});
|
||||
return combine(
|
||||
timestamp(
|
||||
{
|
||||
format: () => dayjs().local().format(),
|
||||
}
|
||||
),
|
||||
l,
|
||||
// l,
|
||||
s,
|
||||
errorAwareFormat,
|
||||
//errorsFormat,
|
||||
defaultFormat,
|
||||
defaultFormat(labelName),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -646,81 +650,111 @@ export const parseLabels = (log: string): string[] => {
|
||||
return Array.from(log.matchAll(LABELS_REGEX), m => m[0]).map(x => x.substring(1, x.length - 1));
|
||||
}
|
||||
|
||||
const SUBREDDIT_NAME_LOG_REGEX: RegExp = /{(.+?)}/;
|
||||
export const parseSubredditLogName = (val:string): string | undefined => {
|
||||
const matches = val.match(SUBREDDIT_NAME_LOG_REGEX);
|
||||
export const parseALogName = (reg: RegExp) => (val: string): string | undefined => {
|
||||
const matches = val.match(reg);
|
||||
if (matches === null) {
|
||||
return undefined;
|
||||
}
|
||||
return matches[1] as string;
|
||||
}
|
||||
|
||||
export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i
|
||||
export const isLogLineMinLevel = (line: string, minLevelText: string): boolean => {
|
||||
const lineLevelMatch = line.match(LOG_LEVEL_REGEX);
|
||||
if (lineLevelMatch === null) {
|
||||
return false;
|
||||
}
|
||||
const SUBREDDIT_NAME_LOG_REGEX: RegExp = /{(.+?)}/;
|
||||
export const parseSubredditLogName = parseALogName(SUBREDDIT_NAME_LOG_REGEX);
|
||||
export const parseSubredditLogInfoName = (logInfo: LogInfo) => logInfo.subreddit;
|
||||
const BOT_NAME_LOG_REGEX: RegExp = /~(.+?)~/;
|
||||
export const parseBotLogName = parseALogName(BOT_NAME_LOG_REGEX);
|
||||
const INSTANCE_NAME_LOG_REGEX: RegExp = /\|(.+?)\|/;
|
||||
export const parseInstanceLogName = parseALogName(INSTANCE_NAME_LOG_REGEX);
|
||||
export const parseInstanceLogInfoName = (logInfo: LogInfo) => logInfo.instance;
|
||||
|
||||
export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i
|
||||
export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: string): boolean => {
|
||||
// @ts-ignore
|
||||
const minLevel = logLevels[minLevelText];
|
||||
// @ts-ignore
|
||||
const level = logLevels[lineLevelMatch[1] as string];
|
||||
let level: number;
|
||||
|
||||
if(typeof log === 'string') {
|
||||
const lineLevelMatch = log.match(LOG_LEVEL_REGEX)
|
||||
if (lineLevelMatch === null) {
|
||||
return false;
|
||||
}
|
||||
// @ts-ignore
|
||||
level = logLevels[lineLevelMatch[1]];
|
||||
} else {
|
||||
const lineLevelMatch = log.level;
|
||||
// @ts-ignore
|
||||
level = logLevels[lineLevelMatch];
|
||||
}
|
||||
return level <= minLevel;
|
||||
}
|
||||
|
||||
// https://regexr.com/3e6m0
|
||||
const HYPERLINK_REGEX: RegExp = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
|
||||
export const formatLogLineToHtml = (val: string) => {
|
||||
const logContent = val
|
||||
export const formatLogLineToHtml = (log: string | LogInfo) => {
|
||||
const val = typeof log === 'string' ? log : log[MESSAGE];
|
||||
const logContent = Autolinker.link(val, {
|
||||
email: false,
|
||||
phone: false,
|
||||
mention: false,
|
||||
hashtag: false,
|
||||
stripPrefix: false,
|
||||
sanitizeHtml: true,
|
||||
})
|
||||
.replace(/(\s*debug\s*):/i, '<span class="debug text-pink-400">$1</span>:')
|
||||
.replace(/(\s*warn\s*):/i, '<span class="warn text-yellow-400">$1</span>:')
|
||||
.replace(/(\s*info\s*):/i, '<span class="info text-blue-300">$1</span>:')
|
||||
.replace(/(\s*error\s*):/i, '<span class="error text-red-400">$1</span>:')
|
||||
.replace(/(\s*verbose\s*):/i, '<span class="error text-purple-400">$1</span>:')
|
||||
.replaceAll('\n', '<br />')
|
||||
.replace(HYPERLINK_REGEX, '<a target="_blank" href="$&">$&</a>');
|
||||
.replaceAll('\n', '<br />');
|
||||
//.replace(HYPERLINK_REGEX, '<a target="_blank" href="$&">$&</a>');
|
||||
return `<div class="logLine">${logContent}</div>`
|
||||
}
|
||||
|
||||
export type LogEntry = [number, string];
|
||||
export type LogEntry = [number, LogInfo];
|
||||
export interface LogOptions {
|
||||
limit: number,
|
||||
level: string,
|
||||
sort: 'ascending' | 'descending',
|
||||
operator?: boolean,
|
||||
user?: string,
|
||||
allLogsParser?: Function
|
||||
allLogName?: string
|
||||
}
|
||||
|
||||
export const filterLogBySubreddit = (logs: Map<string, LogEntry[]>, subreddits: string[] = [], options: LogOptions): Map<string, string[]> => {
|
||||
export const filterLogBySubreddit = (logs: Map<string, LogEntry[]>, validLogCategories: string[] = [], options: LogOptions): Map<string, string[]> => {
|
||||
const {
|
||||
limit,
|
||||
level,
|
||||
sort,
|
||||
operator = false,
|
||||
user
|
||||
user,
|
||||
allLogsParser = parseSubredditLogInfoName,
|
||||
allLogName = 'app'
|
||||
} = options;
|
||||
|
||||
// get map of valid subreddits
|
||||
// get map of valid logs categories
|
||||
const validSubMap: Map<string, LogEntry[]> = new Map();
|
||||
for(const [k, v] of logs) {
|
||||
if(subreddits.includes(k)) {
|
||||
if(validLogCategories.includes(k)) {
|
||||
validSubMap.set(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
// derive 'all'
|
||||
let allLogs = (logs.get('app') || []);
|
||||
let allLogs = (logs.get(allLogName) || []);
|
||||
if(!operator) {
|
||||
// if user is not an operator then we want to filter allLogs to only logs that include categories they can access
|
||||
if(user === undefined) {
|
||||
allLogs = [];
|
||||
} else {
|
||||
allLogs.filter(([time, l]) => {
|
||||
const sub = parseSubredditLogName(l);
|
||||
const sub = allLogsParser(l);
|
||||
return sub !== undefined && sub.includes(user);
|
||||
});
|
||||
}
|
||||
}
|
||||
// then append all other logs to all logs
|
||||
// -- this is fine because we sort and truncate all logs just below this anyway
|
||||
allLogs = Array.from(validSubMap.values()).reduce((acc, logs) => {
|
||||
return acc.concat(logs);
|
||||
},allLogs);
|
||||
@@ -764,22 +798,21 @@ export const totalFromMapStats = (val: Map<any, number>): number => {
|
||||
}
|
||||
|
||||
export const permissions = [
|
||||
'edit',
|
||||
'flair',
|
||||
'history',
|
||||
'identity',
|
||||
'history',
|
||||
'read',
|
||||
'modcontributors',
|
||||
'modflair',
|
||||
'modlog',
|
||||
'modmail',
|
||||
'privatemessages',
|
||||
'modposts',
|
||||
'modself',
|
||||
'mysubreddits',
|
||||
'read',
|
||||
'report',
|
||||
'submit',
|
||||
'wikiread',
|
||||
'wikiedit'
|
||||
'wikiedit',
|
||||
];
|
||||
|
||||
export const boolToString = (val: boolean): string => {
|
||||
@@ -935,3 +968,19 @@ export const createCacheManager = (options: CacheOptions): Cache => {
|
||||
}
|
||||
|
||||
export const randomId = () => crypto.randomBytes(20).toString('hex');
|
||||
|
||||
export const intersect = (a: Array<any>, b: Array<any>) => {
|
||||
const setA = new Set(a);
|
||||
const setB = new Set(b);
|
||||
const intersection = new Set([...setA].filter(x => setB.has(x)));
|
||||
return Array.from(intersection);
|
||||
}
|
||||
|
||||
export const snooLogWrapper = (logger: Logger) => {
|
||||
return {
|
||||
warn: (...args: any[]) => logger.warn(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
debug: (...args: any[]) => logger.debug(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
info: (...args: any[]) => logger.info(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
trace: (...args: any[]) => logger.debug(args.slice(0, 2).join(' '), [args.slice(2)]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"typeRoots": ["./src/Web/types"]
|
||||
},
|
||||
// "compilerOptions": {
|
||||
// "module": "es6",
|
||||
|
||||