Compare commits

...

11 Commits
0.1 ... 0.1.1

Author SHA1 Message Date
FoxxMD
51fb942d34 Fix collapse formatting 2021-06-04 14:04:26 -04:00
FoxxMD
2433610c7f Add documentation on templating 2021-06-04 13:59:44 -04:00
FoxxMD
473e4b7684 Update readme
* Add wikiConfig env
* Collapse for config example
* Config example showcases templating
2021-06-04 13:20:36 -04:00
FoxxMD
020da4b5fe Refactor action parameters to use RuleResults for templating when rendering content 2021-06-04 13:12:18 -04:00
FoxxMD
08c085e3a9 Specify name on rule/check/action must conform to pattern
alphanumeric with spaces, underscore, dashes -- so we can use them to normalize rule results for templating
2021-06-04 13:11:56 -04:00
FoxxMD
c9c42e68f8 Pass manager options back from json build function 2021-06-04 13:10:52 -04:00
FoxxMD
53983475b6 Add more data to RuleResults for use in templating 2021-06-04 13:10:08 -04:00
FoxxMD
1883039391 Content convenience methods
* Use rule result data to make templating more betterer
* Convenience method for extracting item title/content
2021-06-04 13:09:38 -04:00
FoxxMD
574195475f Fix truncate function
Should be removing length of truncate string as well
2021-06-04 13:08:22 -04:00
FoxxMD
cb02345960 Add wikiLocation arg/env for specifying config location 2021-06-04 13:07:48 -04:00
FoxxMD
fc20ee9561 Truncate report content length to 100 characters to fit reddit spec 2021-06-04 10:17:18 -04:00
21 changed files with 294 additions and 67 deletions

View File

@@ -25,7 +25,7 @@ Some feature highlights:
* All rules support skipping behavior based on author criteria -- name, css flair/text, and moderator status
* Docker container support *(coming soon...)*
## Table of Contents
# Table of Contents
* [How It Works](#how-it-works)
* [Installation](#installation)
@@ -84,10 +84,81 @@ Context Bot's [configuration schema](/src/Schema/App.json) conforms to [JSON Sch
I suggest using [Atlassian JSON Schema Viewer](https://json-schema.app/start) ([direct link](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Freddit-context-bot%2Fmaster%2Fsrc%2FSchema%2FApp.json)) so you can view all documentation while also interactively writing and validating your config! From there you can drill down into any object, see its requirements, view an example JSON document, and live-edit your configuration on the right-hand side.
### Action Templating
Actions that can submit text (Report, Comment) 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.
All Actions with `content` have access to this data:
```json5
{
item: {
kind: 'string', // the type of item (comment/submission)
author: 'string', // name of the item author (reddit user)
permalink: 'string', // a url to the item
url: 'string', // if the item is a Submission then its URL (external for link type submission, reddit link for self-posts)
title: 'string', // if the item is a Submission, then the title of the Submission
},
rules: {
// contains all rules that were run and are accessible using the name, lowercased, with all spaces/dashes/underscores removed
}
}
```
The properties of `rules` are accessible using the name, lower-cased, with all spaces/dashes/underscores. If no name is given `kind` is used as `name` Example:
```
"rules": [
{
"name": "My Custom-Recent Activity Rule", // mycustomrecentactivityrule
"kind": "recentActivity"
},
{
// name = repeatsubmission
"kind": "repeatSubmission",
}
]
```
**To see what data is available for individual Rules [consult the schema](#configuration) for each Rule.**
#### Quick Templating Tutorial
<details>
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:
```
myVariable = 50;
myOtherVariable = "a text fragment"
template = "This is my template, the variable is {{myVariable}}, my other variable is {{myOtherVariable}}, and that's it!";
console.log(Mustache.render(template, {myVariable});
// will render...
"This is my template, the variable is 50, my other variable is a text fragment, and that's it!";
```
**Note: When accessing an object or its properties you must use dot notation**
```
const item = {
aProperty: 'something',
anotherObject: {
bProperty: 'something else'
}
}
const content = "My content will render the property {{item.aProperty}} like this, and another nested property {{item.anotherObject.bProperty}} like this."
```
</details>
### Example Config
Below is a configuration fulfilling the example given at the start of this readme:
<details>
<summary>Click to expand configuration</summary>
```json
{
"checks": [
@@ -131,12 +202,7 @@ Below is a configuration fulfilling the example given at the start of this readm
"actions": [
{
"kind": "report",
"content": "CB: self-promo"
},
{
"kind": "comment",
"content": "wiki:botconfig/contextbot/reportSelfPromo",
"distingish": true
"content": "User posted link {{rules.recentactivity.totalCount}} times in {{rules.recentactivity.subCount}} SP subs: {{rules.recentactivity.summary}}"
}
]
}
@@ -144,6 +210,7 @@ Below is a configuration fulfilling the example given at the start of this readm
}
```
</details>
## Usage
@@ -160,6 +227,7 @@ CLI options take precedence over environmental variables
| --refreshToken | REFRESH_TOKEN | **Yes** | A valid refresh token retrieved from completing the oauth flow for a user with your application. |
| --logDir | LOG_DIR | No | The absolute path to where logs should be stored. use `false` to turn off log files. Defaults to `CWD/logs` |
| --logLevel | LOG_LEVEL | No | The minimum level to log at. Uses [Winston Log Levels](https://github.com/winstonjs/winston#logging-levels). Defaults to `info` |
| --wikiConfig | WIKI_CONFIG | No | The location of the bot configuration in the subreddit wiki. Defaults to `botconfig/contextbox` |
### Reddit App??

View File

@@ -4,6 +4,7 @@ import Submission from "snoowrap/dist/objects/Submission";
import dayjs, {Dayjs} from "dayjs";
import {renderContent} from "../Utils/SnoowrapUtils";
import {RichContent} from "../Common/interfaces";
import {RuleResult} from "../Rule";
export const WIKI_DESCRIM = 'wiki:';
@@ -35,7 +36,7 @@ export class CommentAction extends Action {
this.distinguish = distinguish;
}
async handle(item: Comment | Submission, client: Snoowrap): Promise<void> {
async handle(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
if (this.hasWiki && (this.wikiFetched === undefined || Math.abs(dayjs().diff(this.wikiFetched, 'minute')) > 5)) {
try {
const wiki = item.subreddit.getWikiPage(this.wiki as string);
@@ -47,7 +48,7 @@ export class CommentAction extends Action {
}
}
// @ts-ignore
const reply: Comment = await item.reply(renderContent(this.content, item));
const reply: Comment = await item.reply(renderContent(this.content, item, ruleResults));
if (this.lock && item instanceof Submission) {
// @ts-ignore
await item.lock();

View File

@@ -1,10 +1,11 @@
import {ActionJSONConfig, ActionConfig} from "./index";
import Action from "./index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {RuleResult} from "../Rule";
export class LockAction extends Action {
name?: string = 'Lock';
async handle(item: Comment|Submission, client: Snoowrap): Promise<void> {
async handle(item: Comment|Submission, ruleResults: RuleResult[]): Promise<void> {
if (item instanceof Submission) {
// @ts-ignore
await item.lock();

View File

@@ -1,10 +1,11 @@
import {ActionJSONConfig, ActionConfig} from "./index";
import Action from "./index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {RuleResult} from "../Rule";
export class RemoveAction extends Action {
name?: string = 'Remove';
async handle(item: Comment|Submission, client: Snoowrap): Promise<void> {
async handle(item: Comment|Submission, ruleResults: RuleResult[]): Promise<void> {
// @ts-ignore
await item.remove();
}

View File

@@ -1,6 +1,13 @@
import {ActionJSONConfig, ActionConfig, ActionOptions} from "./index";
import Action from "./index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {truncateStringToLength} from "../util";
import {renderContent} from "../Utils/SnoowrapUtils";
import {RuleResult} from "../Rule";
// https://www.reddit.com/dev/api/oauth#POST_api_report
// denotes 100 characters maximum
const reportTrunc = truncateStringToLength(100);
export class ReportAction extends Action {
content: string;
@@ -8,18 +15,20 @@ export class ReportAction extends Action {
constructor(options: ReportActionOptions) {
super(options);
this.content = options.content;
this.content = options.content || '';
}
async handle(item: Comment | Submission, client: Snoowrap): Promise<void> {
async handle(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
const renderedContent = await renderContent(this.content, item, ruleResults);
const truncatedContent = reportTrunc(renderedContent);
// @ts-ignore
await item.report({reason: this.content});
await item.report({reason: truncatedContent});
}
}
export interface ReportActionConfig {
/**
* The text of the report
* The text of the report. If longer than 100 characters will be truncated to "[content]..."
* */
content: string,
}

View File

@@ -1,6 +1,7 @@
import {SubmissionActionConfig} from "./index";
import Action, {ActionJSONConfig} from "../index";
import Snoowrap, {Comment, Submission} from "snoowrap";
import {RuleResult} from "../../Rule";
export class FlairAction extends Action {
text: string;
@@ -16,7 +17,7 @@ export class FlairAction extends Action {
this.css = options.css || '';
}
async handle(item: Comment | Submission, client: Snoowrap): Promise<void> {
async handle(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void> {
if (item instanceof Submission) {
// @ts-ignore
await item.assignFlair({text: this.text, cssClass: this.css})

View File

@@ -1,6 +1,7 @@
import Snoowrap, {Comment, Submission} from "snoowrap";
import {Logger} from "winston";
import {createLabelledLogger, loggerMetaShuffle} from "../util";
import {RuleResult} from "../Rule";
export abstract class Action {
name?: string;
@@ -23,7 +24,7 @@ export abstract class Action {
}
}
abstract handle(item: Comment | Submission, client: Snoowrap): Promise<void>;
abstract handle(item: Comment | Submission, ruleResults: RuleResult[]): Promise<void>;
}
export interface ActionOptions {
@@ -34,7 +35,11 @@ export interface ActionOptions {
export interface ActionConfig {
/**
* A friendly name for this Action
* An optional, but highly recommended, friendly name for this Action. If not present will default to `kind`.
*
* Can only contain letters, numbers, underscore, spaces, and dashes
*
* @pattern ^[a-zA-Z]([\w -]*[\w])?$
* */
name?: string;
}

View File

@@ -125,16 +125,20 @@ export class Check implements ICheck {
return [true, allResults];
}
async runActions(item: Submission | Comment, client: Snoowrap): Promise<void> {
async runActions(item: Submission | Comment, ruleResults: RuleResult[]): Promise<void> {
for (const a of this.actions) {
await a.handle(item, client);
await a.handle(item, ruleResults);
}
}
}
export interface ICheck extends JoinCondition {
/**
* A friendly name for this check (highly recommended) -- EX "repeatCrosspostReport"
* Friendly name for this Check EX "crosspostSpamCheck"
*
* Can only contain letters, numbers, underscore, spaces, and dashes
*
* @pattern ^[a-zA-Z]([\w -]*[\w])?$
* */
name: string,
description?: string,

View File

@@ -7,6 +7,7 @@ import Ajv from 'ajv';
import * as schema from './Schema/App.json';
import {JSONConfig} from "./JsonConfig";
import LoggedError from "./Utils/LoggedError";
import {ManagerOptions} from "./Subreddit/Manager";
const ajv = new Ajv();
@@ -26,13 +27,16 @@ export class ConfigBuilder {
}
}
buildFromJson(config: object): (Array<SubmissionCheck> | Array<CommentCheck>)[] {
buildFromJson(config: object): [Array<SubmissionCheck>,Array<CommentCheck>,ManagerOptions] {
const commentChecks: Array<CommentCheck> = [];
const subChecks: Array<SubmissionCheck> = [];
const valid = ajv.validate(schema, config);
let managerOptions: ManagerOptions = {};
if(valid) {
const validConfig = config as JSONConfig;
for (const jCheck of validConfig.checks) {
const {checks = [], ...rest} = validConfig;
managerOptions = rest;
for (const jCheck of checks) {
if (jCheck.kind === 'comment') {
commentChecks.push(new CommentCheck({...jCheck, logger: this.logger}));
} else if (jCheck.kind === 'submission') {
@@ -45,6 +49,6 @@ export class ConfigBuilder {
throw new LoggedError();
}
return [subChecks, commentChecks];
return [subChecks, commentChecks, managerOptions];
}
}

View File

@@ -43,7 +43,7 @@ export class AuthorRule extends Rule {
}
getKind(): string {
return "Author";
return "author";
}
protected getSpecificPremise(): object {

View File

@@ -97,10 +97,18 @@ export class RecentActivityRule extends Rule {
}
}
if (triggeredOn.length > 0) {
const friendlyText = triggeredOn.map(x => `${x.subreddit}(${x.count})`).join(' | ');
const friendlyText = triggeredOn.map(x => `${x.subreddit}(${x.count})`).join(', ');
const friendly = `Triggered by: ${friendlyText}`;
this.logger.debug(friendly);
return Promise.resolve([true, [this.getResult(true, {result: friendly, data: triggeredOn})]]);
return Promise.resolve([true, [this.getResult(true, {
result: friendly,
data: {
triggeredOn,
summary: friendlyText,
subCount: triggeredOn.length,
totalCount: triggeredOn.reduce((cnt, data) => cnt + data.count, 0)
}
})]]);
}
return Promise.resolve([false, [this.getResult(false)]]);
@@ -133,6 +141,14 @@ export interface RecentActivityRuleOptions extends RecentActivityConfig, RuleOpt
/**
* Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds
*
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
*
* ```
* summary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...
* subCount => Total number of subreddits that hit the threshold
* totalCount => Total number of all activity occurrences in subreddits
* ```
* */
export interface RecentActivityRuleJSONConfig extends RecentActivityConfig, RuleJSONConfig {
kind: 'recentActivity'

View File

@@ -86,7 +86,14 @@ export class RepeatSubmissionRule extends SubmissionRule {
if (consecutivePosts >= this.threshold) {
const result = `Threshold of ${this.threshold} repeats triggered for submission with url ${sub.url}`;
this.logger.debug(result);
return Promise.resolve([true, [this.getResult(true, {result})]]);
return Promise.resolve([true, [this.getResult(true, {
result,
data: {
count: consecutivePosts,
threshold: this.threshold,
url: sub.url,
}
})]]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
@@ -110,7 +117,15 @@ export class RepeatSubmissionRule extends SubmissionRule {
// @ts-ignore
const result = `Threshold of ${this.threshold} repeats triggered for submission with url ${group[0].url}`;
this.logger.debug(result);
return Promise.resolve([true, [this.getResult(true, {result})]]);
return Promise.resolve([true, [this.getResult(true, {
result,
data: {
count: group.length,
threshold: this.threshold,
// @ts-ignore
url: group[0].url,
}
})]]);
}
}
return Promise.resolve([false, [this.getResult(false)]]);
@@ -154,6 +169,14 @@ export interface RepeatSubmissionOptions extends RepeatSubmissionConfig, RuleOpt
}
/**
* Checks a user's history for Submissions with identical content
*
* Available data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):
*
* ```
* count => Total number of repeat Submissions
* threshold => The threshold you configured for this Rule to trigger
* url => Url of the submission that triggered the rule
* ```
* */
export interface RepeatSubmissionJSONConfig extends RepeatSubmissionConfig, SubmissionRuleJSONConfig {
kind: 'repeatSubmission'

View File

@@ -23,7 +23,7 @@ interface ResultContext {
export interface RuleResult extends ResultContext {
premise: RulePremise
name?: string
name: string
triggered: (boolean | null)
}
@@ -32,13 +32,13 @@ export interface Triggerable {
}
export abstract class Rule implements IRule, Triggerable {
name?: string;
name: string;
logger: Logger
authors: AuthorOptions;
constructor(options: RuleOptions) {
const {
name,
name = this.getKind(),
loggerPrefix = '',
logger,
authors: {
@@ -67,7 +67,7 @@ export abstract class Rule implements IRule, Triggerable {
this.logger.debug('Starting rule run');
const existingResult = findResultByPremise(this.getPremise(), existingResults);
if (existingResult) {
return Promise.resolve([existingResult.triggered, [existingResult]]);
return Promise.resolve([existingResult.triggered, [{...existingResult, name: this.name}]]);
}
if (this.authors.include !== undefined && this.authors.include.length > 0) {
for (const auth of this.authors.include) {
@@ -180,7 +180,12 @@ export interface AuthorCriteria {
export interface IRule {
/**
* A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX "repeatCrosspostRule"
* An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.
*
* Can only contain letters, numbers, underscore, spaces, and dashes
*
* name is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.
* @pattern ^[a-zA-Z]([\w -]*[\w])?$
* */
name?: string
/**

View File

@@ -13,7 +13,8 @@
"type": "string"
},
"name": {
"description": "A friendly name for this Action",
"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",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},

View File

@@ -133,7 +133,8 @@
"type": "string"
},
"name": {
"description": "A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX \"repeatCrosspostRule\"",
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
@@ -199,7 +200,8 @@
"type": "string"
},
"name": {
"description": "A friendly name for this check (highly recommended) -- EX \"repeatCrosspostReport\"",
"description": "Friendly name for this Check EX \"crosspostSpamCheck\"\n\nCan only contain letters, numbers, underscore, spaces, and dashes",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"rules": {
@@ -272,7 +274,8 @@
"type": "boolean"
},
"name": {
"description": "A friendly name for this Action",
"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",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"sticky": {
@@ -351,7 +354,8 @@
"type": "string"
},
"name": {
"description": "A friendly name for this Action",
"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",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"text": {
@@ -385,7 +389,8 @@
"type": "string"
},
"name": {
"description": "A friendly name for this Action",
"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",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
@@ -449,7 +454,7 @@
"type": "object"
},
"RecentActivityRuleJSONConfig": {
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds",
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"properties": {
"authors": {
"$ref": "#/definitions/AuthorOptions",
@@ -473,7 +478,8 @@
"type": "string"
},
"name": {
"description": "A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX \"repeatCrosspostRule\"",
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"thresholds": {
@@ -556,7 +562,8 @@
"type": "string"
},
"name": {
"description": "A friendly name for this Action",
"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",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
@@ -570,7 +577,7 @@
"type": "object"
},
"RepeatSubmissionJSONConfig": {
"description": "Checks a user's history for Submissions with identical content",
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authors": {
"$ref": "#/definitions/AuthorOptions",
@@ -614,7 +621,8 @@
"type": "string"
},
"name": {
"description": "A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX \"repeatCrosspostRule\"",
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"threshold": {
@@ -684,7 +692,7 @@
"description": "Report the Activity",
"properties": {
"content": {
"description": "The text of the report",
"description": "The text of the report. If longer than 100 characters will be truncated to \"[content]...\"",
"type": "string"
},
"kind": {
@@ -699,7 +707,8 @@
"type": "string"
},
"name": {
"description": "A friendly name for this Action",
"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",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},

View File

@@ -88,7 +88,8 @@
"type": "string"
},
"name": {
"description": "A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX \"repeatCrosspostRule\"",
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},

View File

@@ -133,7 +133,8 @@
"type": "string"
},
"name": {
"description": "A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX \"repeatCrosspostRule\"",
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
}
},
@@ -190,7 +191,7 @@
"type": "object"
},
"RecentActivityRuleJSONConfig": {
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds",
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"properties": {
"authors": {
"$ref": "#/definitions/AuthorOptions",
@@ -214,7 +215,8 @@
"type": "string"
},
"name": {
"description": "A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX \"repeatCrosspostRule\"",
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"thresholds": {
@@ -283,7 +285,7 @@
"type": "object"
},
"RepeatSubmissionJSONConfig": {
"description": "Checks a user's history for Submissions with identical content",
"description": "Checks a user's history for Submissions with identical content\n\nAvailable data for [Action templating](https://github.com/FoxxMD/reddit-context-bot#action-templating):\n\n```\ncount => Total number of repeat Submissions\nthreshold => The threshold you configured for this Rule to trigger\nurl => Url of the submission that triggered the rule\n```",
"properties": {
"authors": {
"$ref": "#/definitions/AuthorOptions",
@@ -327,7 +329,8 @@
"type": "string"
},
"name": {
"description": "A friendly, descriptive name for this rule. Highly recommended to make it easier to track logs EX \"repeatCrosspostRule\"",
"description": "An optional, but highly recommended, friendly name for this rule. If not present will default to `kind`.\n\nCan only contain letters, numbers, underscore, spaces, and dashes\n\nname is used to reference Rule result data during Action content templating. See CommentAction or ReportAction for more details.",
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"threshold": {

View File

@@ -1,13 +1,19 @@
import Snoowrap, {Comment, Submission, Subreddit} from "snoowrap";
import Snoowrap, {Comment, Subreddit} from "snoowrap";
import {Logger} from "winston";
import {SubmissionCheck} from "../Check/SubmissionCheck";
import {CommentCheck} from "../Check/CommentCheck";
import {createLabelledLogger, determineNewResults, loggerMetaShuffle, mergeArr, sleep} from "../util";
import {
determineNewResults,
loggerMetaShuffle,
mergeArr,
} from "../util";
import {CommentStream, SubmissionStream} from "snoostorm";
import pEvent from "p-event";
import {RuleResult} from "../Rule";
import {ConfigBuilder} from "../ConfigBuilder";
import {PollingOptions} from "../Common/interfaces";
import Submission from "snoowrap/dist/objects/Submission";
import {itemContentPeek} from "../Utils/SnoowrapUtils";
export interface ManagerOptions {
polling?: PollingOptions
@@ -30,8 +36,9 @@ export class Manager {
this.logger = logger.child(loggerMetaShuffle(logger, undefined, [`r/${sub.display_name}`], {truncateLength: 40}), mergeArr);
const configBuilder = new ConfigBuilder({logger: this.logger});
const [subChecks, commentChecks] = configBuilder.buildFromJson(sourceData);
this.pollOptions = opts.polling || {};
const [subChecks, commentChecks, configManagerOptions] = configBuilder.buildFromJson(sourceData);
const {polling = {}} = configManagerOptions || {};
this.pollOptions = {...polling, ...opts.polling};
this.subreddit = sub;
this.client = client;
this.submissionChecks = subChecks;
@@ -48,12 +55,17 @@ export class Manager {
const checks = checkType === 'Comment' ? this.commentChecks : this.submissionChecks;
const itemId = await item.id;
let allRuleResults: RuleResult[] = [];
const itemIdentifier = `${checkType} ${itemId}`;
const [peek, _] = await itemContentPeek(item);
this.logger.debug(`New Event: ${itemIdentifier} => ${peek}`);
for (const check of checks) {
this.logger.debug(`Running Check ${check.name} on ${checkType} (ID ${itemId})`);
this.logger.debug(`Running Check ${check.name} on ${itemIdentifier}`);
let triggered = false;
let currentResults: RuleResult[] = [];
try {
const [checkTriggered, checkResults] = await check.run(item, allRuleResults);
currentResults = checkResults;
allRuleResults = allRuleResults.concat(determineNewResults(allRuleResults, checkResults));
triggered = checkTriggered;
const invokedRules = checkResults.map(x => x.name || x.premise.kind).join(' | ');
@@ -69,7 +81,7 @@ export class Manager {
if (triggered) {
// TODO give actions a name
await check.runActions(item, this.client);
await check.runActions(item, currentResults);
this.logger.debug(`Ran actions for Check ${check.name}`);
break;
}

View File

@@ -1,10 +1,11 @@
import {Comment, RedditUser} from "snoowrap";
import Snoowrap, {Comment, RedditUser} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
import {Duration, DurationUnitsObjectType} from "dayjs/plugin/duration";
import dayjs, {Dayjs} from "dayjs";
import Mustache from "mustache";
import {AuthorOptions, AuthorCriteria} from "../Rule";
import {AuthorOptions, AuthorCriteria, RuleResult} from "../Rule";
import {ActivityWindowCriteria, ActivityWindowType} from "../Common/interfaces";
import {truncateStringToLength} from "../util";
export interface AuthorTypedActivitiesOptions extends AuthorActivitiesOptions {
type?: 'comment' | 'submission',
@@ -83,7 +84,7 @@ export const getAuthorSubmissions = async (user: RedditUser, options: AuthorActi
return await getAuthorActivities(user, {...options, type: 'submission'}) as unknown as Promise<Submission[]>;
}
export const renderContent = async (content: string, data: (Submission | Comment), additionalData = {}) => {
export const renderContent = async (content: string, data: (Submission | Comment), ruleResults: RuleResult[] = []) => {
const templateData: any = {
kind: data instanceof Submission ? 'submission' : 'comment',
author: await data.author.name,
@@ -93,8 +94,31 @@ export const renderContent = async (content: string, data: (Submission | Comment
templateData.url = data.url;
templateData.title = data.title;
}
// 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 = {},
result,
premise: {
kind
}
} = ruleResult;
// 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 = name.trim().replace(/\W+/g, '').toLowerCase()
return {
...acc, [normalName]: {
kind,
triggered,
result,
...data,
}
};
}, {});
return Mustache.render(content, {...templateData, ...additionalData});
return Mustache.render(content, {item: templateData, rules: normalizedRuleResults});
}
export const testAuthorCriteria = async (item: (Comment|Submission), authorOpts: AuthorCriteria, include = true) => {
@@ -157,3 +181,39 @@ export const testAuthorCriteria = async (item: (Comment|Submission), authorOpts:
}
return true;
}
export interface ItemContent {
submissionTitle: string,
content: string,
author: string,
}
export const itemContentPeek = async (item: (Comment | Submission), peekLength = 200): Promise<[string, ItemContent]> => {
const truncatePeek = truncateStringToLength(peekLength);
let content = '';
let submissionTitle = '';
let peek = '';
// @ts-ignore
const client = item._r as Snoowrap;
const author = item.author.name;
if (item instanceof Submission) {
submissionTitle = item.title;
peek = `${truncatePeek(item.title)} by ${author}`;
} else if (item instanceof Comment) {
content = truncatePeek(item.body)
try {
// @ts-ignore
const client = item._r as Snoowrap;
// @ts-ignore
const commentSub = await client.getSubmission(item.link_id);
const [p, {submissionTitle: subTitle}] = await itemContentPeek(commentSub);
submissionTitle = subTitle;
peek = `${truncatePeek(content)} in ${p}`;
} catch (err) {
// possible comment is not on a submission, just swallow
}
}
return [peek, {submissionTitle, content, author}];
}

View File

@@ -24,6 +24,7 @@ const {
refreshToken = process.env.REFRESH_TOKEN,
logDir = process.env.LOG_DIR,
logLevel = process.env.LOG_LEVEL,
wikiConfig = process.env.WIKI_CONFIG,
} = argv;
const logPath = logDir ?? `${process.cwd()}/logs`;
@@ -61,6 +62,8 @@ const logger = winston.loggers.get('default');
const version = process.env.VERSION || 'dev';
const wikiLocation = wikiConfig || 'botconfig/contextbot';
let subredditsArg = subredditsArgs;
if (subredditsArg.length === 0) {
// try to get from comma delim env variable
@@ -98,7 +101,7 @@ if (subredditsArg.length === 0) {
// if user specified subs to run on check they are all subs client can mod
if (subredditsArgs.length > 0) {
for (const sub of subredditsArg) {
const asub = availSubs.find(x => x.name.toLowerCase() === sub.trim().toLowerCase())
const asub = availSubs.find(x => x.display_name.toLowerCase() === sub.trim().toLowerCase())
if (asub === undefined) {
logger.error(`Will not run on ${sub} because is not modded by, or does not have appropriate permissions to mod with, for this client.`);
} else {
@@ -117,7 +120,7 @@ if (subredditsArg.length === 0) {
let content = undefined;
let json = undefined;
try {
const wiki = sub.getWikiPage('contextbot');
const wiki = sub.getWikiPage(wikiLocation);
content = await wiki.content_md;
} catch (err) {
logger.error(`Could not read wiki configuration for ${sub.display_name}. Please ensure the page 'contextbot' exists and is readable -- error: ${err.message}`);

View File

@@ -17,9 +17,9 @@ const SPLAT = Symbol.for('splat')
const errorsFormat = errors({stack: true});
const CWD = process.cwd();
export const truncateStringToLength = (length: number, truncStr = '...') => (str: string) => str.length > length ? `${str.slice(0, length)}${truncStr}` : str;
export const truncateStringToLength = (length: number, truncStr = '...') => (str: string) => str.length > length ? `${str.slice(0, length - truncStr.length - 1)}${truncStr}` : str;
export const loggerMetaShuffle = (logger: Logger, newLeaf: (string | undefined | null) = null, extraLabels: string[] = [], {truncateLength = 15} = {}) => {
export const loggerMetaShuffle = (logger: Logger, newLeaf: (string | undefined | null) = null, extraLabels: string[] = [], {truncateLength = 50} = {}) => {
const labelTrunc = truncateStringToLength(truncateLength);
const {labels = [], leaf} = logger.defaultMeta || {};
return {