Compare commits

..

1 Commits

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,6 @@ PROTIP: Using a container management tool like [Portainer.io CE](https://www.por
An example of starting the container using the [minimum configuration](/docs/operator/configuration.md#minimum-config):
* Bind the directory where your config file, logs, and database are located on your host machine into the container's default `DATA_DIR` by using `-v /host/path/folder:/config`
* Note: **You must do this** or else your configuration will be lost next time your container is updated.
* Expose the web interface using the container port `8085`
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2030,14 +2030,6 @@
},
"name": {
"type": "string"
},
"wikiConfig": {
"default": "botconfig/contextbot",
"description": "The relative URL to the ContextMod wiki page EX `https://reddit.com/r/subreddit/wiki/<path>`\n\nThis will override the default relative URL as well as any URL set at the bot-level",
"examples": [
"botconfig/contextbot"
],
"type": "string"
}
},
"required": [

View File

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

View File

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

View File

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

View File

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

View File

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