mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-14 07:57:57 -05:00
Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51fb942d34 | ||
|
|
2433610c7f | ||
|
|
473e4b7684 | ||
|
|
020da4b5fe | ||
|
|
08c085e3a9 | ||
|
|
c9c42e68f8 | ||
|
|
53983475b6 | ||
|
|
1883039391 | ||
|
|
574195475f | ||
|
|
cb02345960 | ||
|
|
fc20ee9561 |
82
README.md
82
README.md
@@ -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??
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ export class AuthorRule extends Rule {
|
||||
}
|
||||
|
||||
getKind(): string {
|
||||
return "Author";
|
||||
return "author";
|
||||
}
|
||||
|
||||
protected getSpecificPremise(): object {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
/**
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}];
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user