Compare commits

...

21 Commits

Author SHA1 Message Date
FoxxMD
d6954533a0 Merge branch 'edge' 2022-01-10 12:32:14 -05:00
Matt Foxx
04b8762926 Merge pull request #68 from rysie/feature/flair-docs
feat(docs): User flair and submission flair docs
2022-01-10 12:31:56 -05:00
FoxxMD
dcc5f87c30 refactor(docs): Clean up flair docs
* Fix regex escaped characters
* Use authorIs
* make flair action type usage more clear
2022-01-10 12:22:16 -05:00
FoxxMD
66d9c0b2a7 fix(auth): Fix bug allowing any valid moderator to see all instances 2022-01-10 10:27:13 -05:00
FoxxMD
00e7cad423 fix(auth): Logout bot after auth flow is complete 2022-01-10 10:26:45 -05:00
Marcin Macinski
bc541d00d4 feat(docs): User flair and submission flair docs 2022-01-08 00:02:37 +01:00
FoxxMD
ba53233640 Merge branch 'edge' 2022-01-07 09:31:14 -05:00
Matt Foxx
ede86d285b Merge pull request #62 from rysie/user-flair-action
UserFlairAction added
2022-01-06 14:55:27 -05:00
FoxxMD
52f6aabb69 feat: Prevent bot from running on reports/comments it just created
Cache reported items or new comments made by bot for a short time (default to twice polling interval, 1 minute) to prevent bot from running on things it did itself
2022-01-06 14:54:17 -05:00
FoxxMD
18175f3662 feat(item filter): Support checking for different report types: total, user, mod 2022-01-06 13:13:10 -05:00
FoxxMD
68a272d305 fix(ui): Fix subreddit intersection check for bot related routes
Remove any prefixed r/ from a bot's subreddits when checking intersection with user subreddits
2022-01-06 12:29:19 -05:00
FoxxMD
3dac91fafc fix(recent): Fix default behavior for submissionReference based on activity type
Eliminates noisy logging when it's not specified but activity is comment
2022-01-06 12:09:04 -05:00
FoxxMD
e5bb8c2a38 fix(bot): Reduce retries for more aggressive fallback on reddit api issues
* Reduce retry for snoowrap to 2 since we do our own error handling in-app and 2 is enough for the occasional, non-systemic blip
* Reduce manager retries
2022-01-05 20:46:54 -05:00
FoxxMD
61e0baf3fd feat(recent): Add combined karma to template variables 2022-01-05 17:08:15 -05:00
FoxxMD
37e9d1fcc2 fix(polling): Fix set timeout args 2022-01-05 14:28:19 -05:00
FoxxMD
5e70ca1cb6 fix: Fix and improve code related to stopping bots when reddit api is not OK
* Fix polling timeout to actually stop on error by simplifying timeout and waiting until response is OK to recreate next timeout call
* Use "unexpected exception" retry count for all non well-known "reddit blip" responses in retry handler rather than failing immediately AND log this distinction
* Fix managers not emitting errors from checks
* Fix bot not awaiting retry handler on manager error emit
* Increase nanny loop delay on error to reduce api pressure when there are many bots running
* (unrelated) set bot as running before starting managers so UI is available earlier
2022-01-05 12:58:17 -05:00
FoxxMD
7f7ed18927 refactor(server): return app earlier so UI is available earlier
Bot init can finish asynchronously without any negative affect to server/client. Returning earlier means we can access server info earlier in startup
2022-01-05 12:50:55 -05:00
FoxxMD
efed3381fd feat(config): Allow top-level operator snoowrap config 2022-01-05 10:39:43 -05:00
FoxxMD
5ac5d65a28 refactor(userflair): Fix dryrun usage and add unflair functionality
* Can flair user on comment/submission
* fix dryrun if-else block (maybe a debugging artifact?)
* allow all properties to be undefined/null/empty and use as intention to unflair user
2022-01-03 21:02:21 -05:00
Marcin Macinski
51e299ca99 Merge branch 'edge' into user-flair-action 2021-12-22 01:13:33 +01:00
Marcin Macinski
7696f3c2ff UserFlairAction added 2021-12-22 00:45:59 +01:00
37 changed files with 722 additions and 210 deletions

View File

@@ -18,6 +18,7 @@ This directory contains example of valid, ready-to-go configurations for Context
* [Author](/docs/examples/author)
* [Regex](/docs/examples/regex)
* [Repost](/docs/examples/repost)
* [Author and post flairs](/docs/examples/onlyfansFlair)
* [Toolbox User Notes](/docs/examples/userNotes)
* [Advanced Concepts](/docs/examples/advancedConcepts)
* [Rule Sets](/docs/examples/advancedConcepts/ruleSets.json5)

View File

@@ -0,0 +1,9 @@
# Flair users and submissions
Flair users and submissions based on certain keywords from submitter's profile.
Consult [User Flair schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserFlairActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) and [Submission Flair schema](https://json-schema.app/view/%23%2Fdefinitions%2FFlairActionJson?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fcontext-mod%2Fmaster%2Fsrc%2FSchema%2FApp.json) for a complete reference of the rule's properties.
### Examples
* OnlyFans submissions [YAML](/docs/examples/onlyFansFlair/onlyFansFlair.yaml) | [JSON](/docs/examples/onlyfansFlair/onlyfansFlair.json5) - Check whether submitter has typical OF keywords in their profile and flair both author + submission accordingly.

View File

@@ -0,0 +1,68 @@
{
"checks": [
{
"name": "Flair OF submitters",
"description": "Flair submission as OF if user does not have Verified flair and has certain keywords in their profile",
"kind": "submission",
"authorIs": {
"exclude": [
{
"flairCssClass": ["verified"]
}
]
},
"rules": [
{
"name": "OnlyFans strings in description",
"kind": "author",
"include": [
{
"description": [
"/(cashapp|allmylinks|linktr|onlyfans\\.com)/i",
"/(see|check|my|view) (out|of|onlyfans|kik|skype|insta|ig|profile|links)/i",
"my links",
"$"
]
}
]
}
],
"actions": [
{
"name": "Set OnlyFans user flair",
"kind": "userflair",
"flair_template_id": "put-your-onlyfans-user-flair-id-here"
},
{
"name":"Set OF Creator SUBMISSION flair",
"kind": "flair",
"flair_template_id": "put-your-onlyfans-post-flair-id-here"
}
]
},
{
"name": "Flair posts of OF submitters",
"description": "Flair submission as OnlyFans if submitter has OnlyFans userflair (override post flair set by submitter)",
"kind": "submission",
"rules": [
{
"name": "Include OF submitters",
"kind": "author",
"include": [
{
"flairCssClass": ["onlyfans"]
}
]
}
],
"actions": [
{
"name":"Set OF Creator SUBMISSION flair",
"kind": "flair",
"flair_template_id": "put-your-onlyfans-post-flair-id-here"
}
]
}
]
}

View File

@@ -0,0 +1,38 @@
checks:
- name: Flair OF submitters
description: Flair submission as OF if user does not have Verified flair and has
certain keywords in their profile
kind: submission
authorIs:
exclude:
- flairCssClass:
- verified
rules:
- name: OnlyFans strings in description
kind: author
include:
- description:
- '/(cashapp|allmylinks|linktr|onlyfans\.com)/i'
- '/(see|check|my|view) (out|of|onlyfans|kik|skype|insta|ig|profile|links)/i'
- my links
- "$"
actions:
- name: Set OnlyFans user flair
kind: userflair
flair_template_id: put-your-onlyfans-user-flair-id-here
- name: Set OF Creator SUBMISSION flair
kind: flair
flair_template_id: put-your-onlyfans-post-flair-id-here
- name: Flair posts of OF submitters
description: Flair submission as OnlyFans if submitter has OnlyFans userflair (override post flair set by submitter)
kind: submission
rules:
- name: Include OF submitters
kind: author
include:
- flairCssClass:
- onlyfans
actions:
- name: Set OF Creator SUBMISSION flair
kind: flair
flair_template_id: put-your-onlyfans-post-flair-id-here

View File

@@ -10,10 +10,11 @@ import ApproveAction, {ApproveActionConfig} from "./ApproveAction";
import BanAction, {BanActionJson} from "./BanAction";
import {MessageAction, MessageActionJson} from "./MessageAction";
import {SubredditResources} from "../Subreddit/SubredditResources";
import Snoowrap from "snoowrap";
import {UserFlairAction, UserFlairActionJson} from './UserFlairAction';
import {ExtendedSnoowrap} from '../Utils/SnoowrapClients';
export function actionFactory
(config: ActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: Snoowrap): Action {
(config: ActionJson, logger: Logger, subredditName: string, resources: SubredditResources, client: ExtendedSnoowrap): Action {
switch (config.kind) {
case 'comment':
return new CommentAction({...config as CommentActionJson, logger, subredditName, resources, client});
@@ -25,6 +26,8 @@ export function actionFactory
return new ReportAction({...config as ReportActionJson, logger, subredditName, resources, client});
case 'flair':
return new FlairAction({...config as FlairActionJson, logger, subredditName, resources, client});
case 'userflair':
return new UserFlairAction({...config as UserFlairActionJson, logger, subredditName, resources, client});
case 'approve':
return new ApproveAction({...config as ApproveActionConfig, logger, subredditName, resources, client});
case 'usernote':

View File

@@ -11,6 +11,7 @@ export class ApproveAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionProcessResult> {
const dryRun = runtimeDryrun || this.dryRun;
const touchedEntities = [];
//snoowrap typing issue, thinks comments can't be locked
// @ts-ignore
if (item.approved) {
@@ -23,11 +24,12 @@ export class ApproveAction extends Action {
}
if (!dryRun) {
// @ts-ignore
await item.approve();
touchedEntities.push(await item.approve());
}
return {
dryRun,
success: true,
touchedEntities
}
}
}

View File

@@ -39,6 +39,7 @@ export class BanAction extends Action {
const renderedBody = content === undefined ? undefined : await renderContent(content, item, ruleResults, this.resources.userNotes);
const renderedContent = renderedBody === undefined ? undefined : `${renderedBody}${await this.resources.generateFooter(item, this.footer)}`;
const touchedEntities = [];
let banPieces = [];
banPieces.push(`Message: ${renderedContent === undefined ? 'None' : `${renderedContent.length > 100 ? `\r\n${renderedContent}` : renderedContent}`}`);
banPieces.push(`Reason: ${this.reason || 'None'}`);
@@ -50,18 +51,20 @@ export class BanAction extends Action {
// @ts-ignore
const fetchedSub = await item.subreddit.fetch();
const fetchedName = await item.author.name;
await fetchedSub.banUser({
const bannedUser = await fetchedSub.banUser({
name: fetchedName,
banMessage: renderedContent === undefined ? undefined : renderedContent,
banReason: this.reason,
banNote: this.note,
duration: this.duration
});
touchedEntities.push(bannedUser);
}
return {
dryRun,
success: true,
result: `Banned ${item.author.name} ${durText}${this.reason !== undefined ? ` (${this.reason})` : ''}`
result: `Banned ${item.author.name} ${durText}${this.reason !== undefined ? ` (${this.reason})` : ''}`,
touchedEntities
};
}
}

View File

@@ -51,16 +51,19 @@ export class CommentAction extends Action {
result: 'Cannot comment because Item is archived'
};
}
const touchedEntities = [];
let reply: Comment;
if(!dryRun) {
// @ts-ignore
reply = await item.reply(renderedContent);
touchedEntities.push(reply);
}
if (this.lock) {
if (!dryRun) {
// snoopwrap typing issue, thinks comments can't be locked
// @ts-ignore
await item.lock();
touchedEntities.push(item);
}
}
if (this.distinguish && !dryRun) {
@@ -78,7 +81,8 @@ export class CommentAction extends Action {
return {
dryRun,
success: true,
result: `${modifierStr}${this.lock ? ' - Locked Author\'s Activity - ' : ''}${truncateStringToLength(100)(body)}`
result: `${modifierStr}${this.lock ? ' - Locked Author\'s Activity - ' : ''}${truncateStringToLength(100)(body)}`,
touchedEntities,
};
}
}

View File

@@ -11,6 +11,7 @@ export class LockAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionProcessResult> {
const dryRun = runtimeDryrun || this.dryRun;
const touchedEntities = [];
//snoowrap typing issue, thinks comments can't be locked
// @ts-ignore
if (item.locked) {
@@ -25,10 +26,12 @@ export class LockAction extends Action {
//snoowrap typing issue, thinks comments can't be locked
// @ts-ignore
await item.lock();
touchedEntities.push(item);
}
return {
dryRun,
success: true
success: true,
touchedEntities
}
}
}

View File

@@ -12,6 +12,7 @@ export class RemoveAction extends Action {
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionProcessResult> {
const dryRun = runtimeDryrun || this.dryRun;
const touchedEntities = [];
// issue with snoowrap typings, doesn't think prop exists on Submission
// @ts-ignore
if (activityIsRemoved(item)) {
@@ -24,11 +25,13 @@ export class RemoveAction extends Action {
if (!dryRun) {
// @ts-ignore
await item.remove();
touchedEntities.push(item);
}
return {
dryRun,
success: true,
touchedEntities
}
}
}

View File

@@ -29,15 +29,20 @@ export class ReportAction extends Action {
const renderedContent = await renderContent(content, item, ruleResults, this.resources.userNotes);
this.logger.verbose(`Contents:\r\n${renderedContent}`);
const truncatedContent = reportTrunc(renderedContent);
const touchedEntities = [];
if(!dryRun) {
// @ts-ignore
await item.report({reason: truncatedContent});
// due to reddit not updating this in response (maybe)?? just increment stale activity
item.num_reports++;
touchedEntities.push(item);
}
return {
dryRun,
success: true,
result: truncatedContent
result: truncatedContent,
touchedEntities
};
}
}

View File

@@ -0,0 +1,109 @@
import Action, {ActionConfig, ActionJson, ActionOptions} from './index';
import {Comment, RedditUser, Submission} from 'snoowrap';
import {RuleResult} from '../Rule';
import {ActionProcessResult} from '../Common/interfaces';
export class UserFlairAction extends Action {
text?: string;
css?: string;
flair_template_id?: string;
constructor(options: UserFlairActionOptions) {
super(options);
this.text = options.text === null || options.text === '' ? undefined : options.text;
this.css = options.css === null || options.text === '' ? undefined : options.text;
this.flair_template_id = options.flair_template_id === null || options.flair_template_id === '' ? undefined : options.flair_template_id;
}
getKind() {
return 'User Flair';
}
async process(item: Comment | Submission, ruleResults: RuleResult[], runtimeDryrun?: boolean): Promise<ActionProcessResult> {
const dryRun = runtimeDryrun || this.dryRun;
let flairParts = [];
if (this.flair_template_id !== undefined) {
flairParts.push(`Flair template ID: ${this.flair_template_id}`)
if(this.text !== undefined || this.css !== undefined) {
this.logger.warn('Text/CSS properties will be ignored since a flair template is specified');
}
} else {
if (this.text !== undefined) {
flairParts.push(`Text: ${this.text}`);
}
if (this.css !== undefined) {
flairParts.push(`CSS: ${this.css}`);
}
}
const flairSummary = flairParts.length === 0 ? 'Unflair user' : flairParts.join(' | ');
this.logger.verbose(flairSummary);
if (!this.dryRun) {
if (this.flair_template_id !== undefined) {
try {
// @ts-ignore
await this.client.assignUserFlairByTemplateId({
subredditName: item.subreddit.display_name,
flairTemplateId: this.flair_template_id,
username: item.author.name,
});
} catch (err: any) {
this.logger.error('Either the flair template ID is incorrect or you do not have permission to access it.');
throw err;
}
} else if (this.text === undefined && this.css === undefined) {
// @ts-ignore
await item.subreddit.deleteUserFlair(item.author.name);
} else {
// @ts-ignore
await item.author.assignFlair({
subredditName: item.subreddit.display_name,
cssClass: this.css,
text: this.text,
});
}
}
return {
dryRun,
success: true,
result: flairSummary,
}
}
}
/**
* Flair the Author of an Activity
*
* Leave all properties blank or null to remove a User's existing flair
* */
export interface UserFlairActionConfig extends ActionConfig {
/**
* The text of the flair to apply
* */
text?: string,
/**
* The text of the css class of the flair to apply
* */
css?: string,
/**
* Flair template to pick.
*
* **Note:** If this template is used text/css are ignored
* */
flair_template_id?: string;
}
export interface UserFlairActionOptions extends UserFlairActionConfig, ActionOptions {
}
/**
* Flair the Submission
* */
export interface UserFlairActionJson extends UserFlairActionConfig, ActionJson {
kind: 'userflair'
}

View File

@@ -1,4 +1,4 @@
import Snoowrap, {Comment, Submission} from "snoowrap";
import {Comment, Submission} from "snoowrap";
import {Logger} from "winston";
import {RuleResult} from "../Rule";
import {SubredditResources} from "../Subreddit/SubredditResources";
@@ -6,12 +6,13 @@ import {ActionProcessResult, ActionResult, ChecksActivityState, TypedActivitySta
import Author, {AuthorOptions} from "../Author/Author";
import {mergeArr} from "../util";
import LoggedError from "../Utils/LoggedError";
import {ExtendedSnoowrap} from '../Utils/SnoowrapClients';
export abstract class Action {
name?: string;
logger: Logger;
resources: SubredditResources;
client: Snoowrap
client: ExtendedSnoowrap;
authorIs: AuthorOptions;
itemIs: TypedActivityStates;
dryRun: boolean;
@@ -114,8 +115,8 @@ export abstract class Action {
export interface ActionOptions extends ActionConfig {
logger: Logger;
subredditName: string;
resources: SubredditResources
client: Snoowrap
resources: SubredditResources;
client: ExtendedSnoowrap;
}
export interface ActionConfig extends ChecksActivityState {
@@ -162,7 +163,7 @@ export interface ActionJson extends ActionConfig {
/**
* The type of action that will be performed
*/
kind: 'comment' | 'lock' | 'remove' | 'report' | 'approve' | 'ban' | 'flair' | 'usernote' | 'message'
kind: 'comment' | 'lock' | 'remove' | 'report' | 'approve' | 'ban' | 'flair' | 'usernote' | 'message' | 'userflair'
}
export const isActionJson = (obj: object): obj is ActionJson => {

View File

@@ -178,7 +178,7 @@ class Bot {
this.client = proxy === undefined ? new ExtendedSnoowrap(creds) : new ProxiedSnoowrap({...creds, proxy});
this.client.config({
warnings: true,
maxRetryAttempts: 5,
maxRetryAttempts: 2,
debug,
logger: snooLogWrapper(this.logger.child({labels: ['Snoowrap']}, mergeArr)),
continueAfterRatelimitError: false,
@@ -190,14 +190,14 @@ class Bot {
}
}
const retryHandler = createRetryHandler({maxRequestRetry: 8, maxOtherRetry: 1}, this.logger);
const retryHandler = createRetryHandler({maxRequestRetry: 8, maxOtherRetry: 2}, this.logger);
this.nannyRetryHandler = createRetryHandler({maxRequestRetry: 5, maxOtherRetry: 1}, this.logger);
this.managerRetryHandler = createRetryHandler({maxRequestRetry: 5, maxOtherRetry: 10, waitOnRetry: false, clearRetryCountAfter: 2}, this.logger);
this.managerRetryHandler = createRetryHandler({maxRequestRetry: 8, maxOtherRetry: 8, waitOnRetry: false, clearRetryCountAfter: 2}, this.logger);
this.stagger = stagger ?? 2000;
const modStreamErrorListener = (name: string) => async (err: any) => {
this.logger.error('Polling error occurred', err);
this.logger.error(`Polling error occurred on stream ${name.toUpperCase()}`, err);
const shouldRetry = await retryHandler(err);
if(shouldRetry) {
defaultUnmoderatedStream.startInterval();
@@ -373,9 +373,10 @@ class Bot {
// if the cumulative errors exceeds configured threshold then stop ALL managers as there is most likely something very bad happening
async panicOnRetries(err: any) {
if(!this.managerRetryHandler(err)) {
if(!await this.managerRetryHandler(err)) {
this.logger.warn('Bot detected too many errors from managers within a short time. Stopping all managers and will try to restart on next heartbeat.');
for(const m of this.subManagers) {
await m.stop();
await m.stop('system',{reason: 'Bot detected too many errors from all managers. Stopping all manager as a failsafe.'});
}
}
}
@@ -443,6 +444,8 @@ class Bot {
}
async runManagers(causedBy: Invokee = 'system') {
this.running = true;
if(this.subManagers.every(x => !x.validConfigLoaded)) {
this.logger.warn('All managers have invalid configs!');
this.error = 'All managers have invalid configs';
@@ -456,7 +459,6 @@ class Bot {
await this.runModStreams();
this.running = true;
this.nextNannyCheck = dayjs().add(10, 'second');
this.nextHeartbeat = dayjs().add(this.heartbeatInterval, 'second');
await this.checkModInvites();
@@ -474,8 +476,8 @@ class Bot {
await this.runApiNanny();
this.nextNannyCheck = dayjs().add(10, 'second');
} catch (err: any) {
this.logger.info('Delaying next nanny check for 2 minutes due to emitted error');
this.nextNannyCheck = dayjs().add(120, 'second');
this.logger.info('Delaying next nanny check for 4 minutes due to emitted error');
this.nextNannyCheck = dayjs().add(240, 'second');
}
}
if(dayjs().isSameOrAfter(this.nextHeartbeat)) {

View File

@@ -29,7 +29,8 @@ import * as RuleSetSchema from '../Schema/RuleSet.json';
import * as ActionSchema from '../Schema/Action.json';
import {ActionObjectJson, RuleJson, RuleObjectJson, ActionJson as ActionTypeJson} from "../Common/types";
import {SubredditResources} from "../Subreddit/SubredditResources";
import {Author, AuthorCriteria, AuthorOptions} from "../Author/Author";
import {Author, AuthorCriteria, AuthorOptions} from '..';
import {ExtendedSnoowrap} from '../Utils/SnoowrapClients';
const checkLogName = truncateStringToLength(25);
@@ -50,7 +51,7 @@ export abstract class Check implements ICheck {
dryRun?: boolean;
notifyOnTrigger: boolean;
resources: SubredditResources;
client: Snoowrap;
client: ExtendedSnoowrap;
constructor(options: CheckOptions) {
const {
@@ -345,13 +346,13 @@ export interface ICheck extends JoinCondition, ChecksActivityState {
}
export interface CheckOptions extends ICheck {
rules: Array<IRuleSet | IRule>
actions: ActionConfig[]
logger: Logger
subredditName: string
notifyOnTrigger?: boolean
resources: SubredditResources
client: Snoowrap
rules: Array<IRuleSet | IRule>;
actions: ActionConfig[];
logger: Logger;
subredditName: string;
notifyOnTrigger?: boolean;
resources: SubredditResources;
client: ExtendedSnoowrap;
cacheUserResult?: UserResultCacheOptions;
}

View File

@@ -1,7 +1,7 @@
import {HistoricalStats} from "./interfaces";
export const cacheOptDefaults = {ttl: 60, max: 500, checkPeriod: 600};
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300, submissionTTL: 60, commentTTL: 60, filterCriteriaTTL: 60, subredditTTL: 600};
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300, submissionTTL: 60, commentTTL: 60, filterCriteriaTTL: 60, subredditTTL: 600, selfTTL: 60};
export const historicalDefaults: HistoricalStats = {
eventsCheckedTotal: 0,
eventsActionedTotal: 0,

View File

@@ -5,6 +5,9 @@ import Poll from "snoostorm/out/util/Poll";
import Snoowrap from "snoowrap";
import {RuleResult} from "../Rule";
import {IncomingMessage} from "http";
import Submission from "snoowrap/dist/objects/Submission";
import Comment from "snoowrap/dist/objects/Comment";
import RedditUser from "snoowrap/dist/objects/RedditUser";
/**
* An ISO 8601 Duration
@@ -670,6 +673,24 @@ export interface TTLConfig {
* @default 60
* */
filterCriteriaTTL?: number | boolean;
/**
* Amount of time, in seconds, an Activity that the bot has acted on or created will be ignored if found during polling
*
* This is useful to prevent the bot from checking Activities it *just* worked on or a product of the checks. Examples:
*
* * Ignore comments created through an Action
* * Ignore Activity polled from modqueue that the bot just reported
*
* This value should be at least as long as the longest polling interval for modqueue/newComm
*
* * If `0` or `true` will cache indefinitely (not recommended)
* * If `false` will not cache
*
* @examples [50]
* @default 50
* */
selfTTL?: number | boolean
}
export interface CacheConfig extends TTLConfig {
@@ -909,6 +930,20 @@ export interface ActivityState {
distinguished?: boolean
approved?: boolean
score?: CompareValue
/**
* A string containing a comparison operator and a value to compare against
*
* The syntax is `(< OR > OR <= OR >=) <number>`
*
* * EX `> 2` => greater than 2 total reports
*
* Defaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:
*
* * EX `> 3 mod` => greater than 3 mod reports
* * EX `>= 1 user` => greater than 1 user report
*
* @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$
* */
reports?: CompareValue
age?: DurationComparor
}
@@ -1072,6 +1107,7 @@ export type StrongCache = {
submissionTTL: number | boolean,
commentTTL: number | boolean,
subredditTTL: number | boolean,
selfTTL: number | boolean,
filterCriteriaTTL: number | boolean,
provider: CacheOptions
actionedEventsMax?: number,
@@ -1289,6 +1325,32 @@ export interface WebCredentials {
redirectUri?: string,
}
export interface SnoowrapOptions {
/**
* Proxy all requests to Reddit's API through this endpoint
*
* * ENV => `PROXY`
* * ARG => `--proxy <proxyEndpoint>`
*
* @examples ["http://localhost:4443"]
* */
proxy?: string,
/**
* Manually set the debug status for snoowrap
*
* When snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level
*
* * Set to `true` to always output
* * Set to `false` to never output
*
* If not present or `null` will be set based on `logLevel`
*
* * ENV => `SNOO_DEBUG`
* * ARG => `--snooDebug`
* */
debug?: boolean,
}
/**
* The configuration for an **individual reddit account** ContextMod will run as a bot.
*
@@ -1309,33 +1371,13 @@ export interface BotInstanceJsonConfig {
notifications?: NotificationConfig
/**
* Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior
* Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior.
*
* Overrides any defaults provided at top-level operator config.
*
* Set to an empty object to "ignore" any top-level config
* */
snoowrap?: {
/**
* Proxy all requests to Reddit's API through this endpoint
*
* * ENV => `PROXY`
* * ARG => `--proxy <proxyEndpoint>`
*
* @examples ["http://localhost:4443"]
* */
proxy?: string,
/**
* Manually set the debug status for snoowrap
*
* When snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level
*
* * Set to `true` to always output
* * Set to `false` to never output
*
* If not present or `null` will be set based on `logLevel`
*
* * ENV => `SNOO_DEBUG`
* * ARG => `--snooDebug`
* */
debug?: boolean,
}
snoowrap?: SnoowrapOptions
/**
* Settings related to bot behavior for subreddits it is managing
@@ -1558,6 +1600,11 @@ export interface OperatorJsonConfig {
* */
caching?: OperatorCacheConfig
/**
* Set global snoowrap options as well as default snoowrap config for all bots that don't specify their own
* */
snoowrap?: SnoowrapOptions
bots?: BotInstanceJsonConfig[]
/**
@@ -1801,20 +1848,18 @@ export interface LogInfo {
bot?: string
}
export interface ActionResult {
export interface ActionResult extends ActionProcessResult {
kind: string,
name: string,
run: boolean,
runReason?: string,
dryRun: boolean,
success: boolean,
result?: string,
}
export interface ActionProcessResult {
success: boolean,
dryRun: boolean,
result?: string
touchedEntities?: (Submission | Comment | RedditUser | string)[]
}
export interface ActionedEvent {
@@ -1851,6 +1896,14 @@ export interface StatusCodeError extends Error {
error: Error
}
export interface RequestError extends Error {
name: 'RequestError',
statusCode: number,
message: string,
response: IncomingMessage,
error: Error
}
export interface HistoricalStatsDisplay extends HistoricalStats {
checksRunTotal: number
checksFromCacheTotal: number

View File

@@ -3,6 +3,7 @@ import {RepeatActivityJSONConfig} from "../Rule/RepeatActivityRule";
import {AuthorRuleJSONConfig} from "../Rule/AuthorRule";
import {AttributionJSONConfig} from "../Rule/AttributionRule";
import {FlairActionJson} from "../Action/SubmissionAction/FlairAction";
import {UserFlairActionJson} from "../Action/UserFlairAction";
import {CommentActionJson} from "../Action/CommentAction";
import {ReportActionJson} from "../Action/ReportAction";
import {LockActionJson} from "../Action/LockAction";
@@ -18,7 +19,7 @@ import {RepostRuleJSONConfig} from "../Rule/RepostRule";
export type RuleJson = RecentActivityRuleJSONConfig | RepeatActivityJSONConfig | AuthorRuleJSONConfig | AttributionJSONConfig | HistoryJSONConfig | RegexRuleJSONConfig | RepostRuleJSONConfig | string;
export type RuleObjectJson = Exclude<RuleJson, string>
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | string;
export type ActionJson = CommentActionJson | FlairActionJson | ReportActionJson | LockActionJson | RemoveActionJson | ApproveActionJson | BanActionJson | UserNoteActionJson | MessageActionJson | UserFlairActionJson | string;
export type ActionObjectJson = Exclude<ActionJson, string>;
// borrowed from https://github.com/jabacchetta/set-random-interval/blob/master/src/index.ts

View File

@@ -281,8 +281,6 @@ export const parseDefaultBotInstanceFromArgs = (args: any): BotInstanceJsonConfi
heartbeat,
hardLimit,
authorTTL,
snooProxy,
snooDebug,
sharedMod,
caching,
} = args || {};
@@ -294,10 +292,6 @@ export const parseDefaultBotInstanceFromArgs = (args: any): BotInstanceJsonConfi
accessToken,
refreshToken,
},
snoowrap: {
proxy: snooProxy,
debug: snooDebug,
},
subreddits: {
names: subreddits,
wikiConfig,
@@ -330,6 +324,8 @@ export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
mode,
caching,
authorTTL,
snooProxy,
snooDebug,
} = args || {};
const data = {
@@ -346,6 +342,10 @@ export const parseOpConfigFromArgs = (args: any): OperatorJsonConfig => {
provider: caching,
authorTTL
},
snoowrap: {
proxy: snooProxy,
debug: snooDebug,
},
web: {
enabled: web,
port,
@@ -401,10 +401,6 @@ export const parseDefaultBotInstanceFromEnv = (): BotInstanceJsonConfig => {
dryRun: parseBool(process.env.DRYRUN, undefined),
heartbeatInterval: process.env.HEARTBEAT !== undefined ? parseInt(process.env.HEARTBEAT) : undefined,
},
snoowrap: {
proxy: process.env.PROXY,
debug: parseBool(process.env.SNOO_DEBUG, undefined),
},
polling: {
sharedMod: parseBool(process.env.SHARE_MOD),
},
@@ -435,6 +431,10 @@ export const parseOpConfigFromEnv = (): OperatorJsonConfig => {
},
authorTTL: process.env.AUTHOR_TTL !== undefined ? parseInt(process.env.AUTHOR_TTL) : undefined
},
snoowrap: {
proxy: process.env.PROXY,
debug: parseBool(process.env.SNOO_DEBUG, undefined),
},
web: {
port: process.env.PORT !== undefined ? parseInt(process.env.PORT) : undefined,
session: {
@@ -568,6 +568,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
credentials: webCredentials,
operators,
} = {},
snoowrap: snoowrapOp = {},
api: {
port: apiPort = 8095,
secret: apiSecret = randomId(),
@@ -640,7 +641,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
softLimit = 250,
hardLimit = 50
} = {},
snoowrap = {},
snoowrap = snoowrapOp,
credentials = {},
subreddits: {
names = [],

View File

@@ -43,7 +43,7 @@ const parseLink = parseUsableLinkIdentifier();
export class RecentActivityRule extends Rule {
window: ActivityWindowType;
thresholds: ActivityThreshold[];
useSubmissionAsReference: boolean;
useSubmissionAsReference: boolean | undefined;
imageDetection: StrongImageDetection
lookAt?: 'comments' | 'submissions';
@@ -51,7 +51,7 @@ export class RecentActivityRule extends Rule {
super(options);
const {
window = 15,
useSubmissionAsReference = true,
useSubmissionAsReference,
imageDetection,
lookAt,
} = options || {};
@@ -128,7 +128,13 @@ export class RecentActivityRule extends Rule {
}
let viableActivity = activities;
if (this.useSubmissionAsReference) {
// if config does not specify reference then we set the default based on whether the item is a submission or not
// -- this is essentially the same as defaulting reference to true BUT eliminates noisy "can't use comment as reference" log statement when item is a comment
let inferredSubmissionAsRef = this.useSubmissionAsReference;
if(inferredSubmissionAsRef === undefined) {
inferredSubmissionAsRef = isSubmission(item);
}
if (inferredSubmissionAsRef) {
if (!asSubmission(item)) {
this.logger.warn('Cannot use post as reference because triggered item is not a Submission');
} else if (item.is_self) {
@@ -421,6 +427,7 @@ export class RecentActivityRule extends Rule {
threshold,
testValue,
karmaThreshold,
combinedKarma,
}
};
}
@@ -501,6 +508,16 @@ interface RecentActivityConfig extends ActivityWindow, ReferenceSubmission {
thresholds: ActivityThreshold[],
imageDetection?: ImageDetection
/**
* When Activity is a submission should we only include activities that are other submissions with the same content?
*
* * When the Activity is a submission this defaults to **true**
* * When the Activity is a comment it is ignored (not relevant)
*
* @default true
* */
useSubmissionAsReference?: boolean
}
export interface RecentActivityRuleOptions extends RecentActivityConfig, RuleOptions {

View File

@@ -189,7 +189,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
@@ -263,7 +263,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
@@ -391,6 +391,7 @@
"message",
"remove",
"report",
"userflair",
"usernote"
],
"type": "string"

View File

@@ -856,6 +856,17 @@
],
"description": "The cache provider and, optionally, a custom configuration for that provider\n\nIf not present or `null` provider will be `memory`.\n\nTo specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`"
},
"selfTTL": {
"default": 50,
"description": "Amount of time, in seconds, an Activity that the bot has acted on or created will be ignored if found during polling\n\nThis is useful to prevent the bot from checking Activities it *just* worked on or a product of the checks. Examples:\n\n* Ignore comments created through an Action\n* Ignore Activity polled from modqueue that the bot just reported\n\nThis value should be at least as long as the longest polling interval for modqueue/newComm\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
"examples": [
50
],
"type": [
"number",
"boolean"
]
},
"submissionTTL": {
"default": 60,
"description": "Amount of time, in seconds, a submission should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
@@ -1118,6 +1129,9 @@
{
"$ref": "#/definitions/FlairActionJson"
},
{
"$ref": "#/definitions/UserFlairActionJson"
},
{
"$ref": "#/definitions/CommentActionJson"
},
@@ -1319,7 +1333,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
@@ -1465,6 +1479,10 @@
],
"type": "boolean"
},
"flair_template_id": {
"description": "Flair template ID to assign",
"type": "string"
},
"itemIs": {
"anyOf": [
{
@@ -2215,7 +2233,7 @@
},
"useSubmissionAsReference": {
"default": true,
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
"description": "When Activity is a submission should we only include activities that are other submissions with the same content?\n\n* When the Activity is a submission this defaults to **true**\n* When the Activity is a comment it is ignored (not relevant)",
"type": "boolean"
},
"window": {
@@ -3192,6 +3210,9 @@
{
"$ref": "#/definitions/FlairActionJson"
},
{
"$ref": "#/definitions/UserFlairActionJson"
},
{
"$ref": "#/definitions/CommentActionJson"
},
@@ -3400,7 +3421,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
@@ -3493,6 +3514,95 @@
],
"type": "string"
},
"UserFlairActionJson": {
"description": "Flair the Submission",
"properties": {
"authorIs": {
"$ref": "#/definitions/AuthorOptions",
"description": "If present then these Author criteria are checked before running the Action. If criteria fails then the Action is not run.",
"examples": [
{
"include": [
{
"flairText": [
"Contributor",
"Veteran"
]
},
{
"isMod": true
}
]
}
]
},
"css": {
"description": "The text of the css class of the flair to apply",
"type": "string"
},
"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"
},
"flair_template_id": {
"description": "Flair template to pick.\n\n**Note:** If this template is used text/css are ignored",
"type": "string"
},
"itemIs": {
"anyOf": [
{
"items": {
"$ref": "#/definitions/SubmissionState"
},
"type": "array"
},
{
"items": {
"$ref": "#/definitions/CommentState"
},
"type": "array"
}
],
"description": "A list of criteria to test the state of the `Activity` against before running the Action.\n\nIf any set of criteria passes the Action will be run."
},
"kind": {
"description": "The type of action that will be performed",
"enum": [
"userflair"
],
"type": "string"
},
"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"
},
"text": {
"description": "The text of the flair to apply",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
"UserNoteActionJson": {
"description": "Add a Toolbox User Note to the Author of this Activity",
"properties": {

View File

@@ -124,21 +124,8 @@
"type": "object"
},
"snoowrap": {
"description": "Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior",
"properties": {
"debug": {
"description": "Manually set the debug status for snoowrap\n\nWhen snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level\n\n* Set to `true` to always output\n* Set to `false` to never output\n\nIf not present or `null` will be set based on `logLevel`\n\n* ENV => `SNOO_DEBUG`\n* ARG => `--snooDebug`",
"type": "boolean"
},
"proxy": {
"description": "Proxy all requests to Reddit's API through this endpoint\n\n* ENV => `PROXY`\n* ARG => `--proxy <proxyEndpoint>`",
"examples": [
"http://localhost:4443"
],
"type": "string"
}
},
"type": "object"
"$ref": "#/definitions/SnoowrapOptions",
"description": "Settings to control some [Snoowrap](https://github.com/not-an-aardvark/snoowrap) behavior.\n\nOverrides any defaults provided at top-level operator config.\n\nSet to an empty object to \"ignore\" any top-level config"
},
"subreddits": {
"description": "Settings related to bot behavior for subreddits it is managing",
@@ -414,6 +401,17 @@
],
"description": "The cache provider and, optionally, a custom configuration for that provider\n\nIf not present or `null` provider will be `memory`.\n\nTo specify another `provider` but use its default configuration set this property to a string of one of the available providers: `memory`, `redis`, or `none`"
},
"selfTTL": {
"default": 50,
"description": "Amount of time, in seconds, an Activity that the bot has acted on or created will be ignored if found during polling\n\nThis is useful to prevent the bot from checking Activities it *just* worked on or a product of the checks. Examples:\n\n* Ignore comments created through an Action\n* Ignore Activity polled from modqueue that the bot just reported\n\nThis value should be at least as long as the longest polling interval for modqueue/newComm\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
"examples": [
50
],
"type": [
"number",
"boolean"
]
},
"submissionTTL": {
"default": 60,
"description": "Amount of time, in seconds, a submission should be cached\n\n* If `0` or `true` will cache indefinitely (not recommended)\n* If `false` will not cache",
@@ -529,6 +527,22 @@
},
"type": "object"
},
"SnoowrapOptions": {
"properties": {
"debug": {
"description": "Manually set the debug status for snoowrap\n\nWhen snoowrap has `debug: true` it will log the http status response of reddit api requests to at the `debug` level\n\n* Set to `true` to always output\n* Set to `false` to never output\n\nIf not present or `null` will be set based on `logLevel`\n\n* ENV => `SNOO_DEBUG`\n* ARG => `--snooDebug`",
"type": "boolean"
},
"proxy": {
"description": "Proxy all requests to Reddit's API through this endpoint\n\n* ENV => `PROXY`\n* ARG => `--proxy <proxyEndpoint>`",
"examples": [
"http://localhost:4443"
],
"type": "string"
}
},
"type": "object"
},
"ThirdPartyCredentialsJsonConfig": {
"additionalProperties": {
},
@@ -694,6 +708,10 @@
},
"type": "object"
},
"snoowrap": {
"$ref": "#/definitions/SnoowrapOptions",
"description": "Set global snoowrap options as well as default snoowrap config for all bots that don't specify their own"
},
"web": {
"description": "Settings for the web interface",
"properties": {

View File

@@ -666,7 +666,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
@@ -1148,7 +1148,7 @@
},
"useSubmissionAsReference": {
"default": true,
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
"description": "When Activity is a submission should we only include activities that are other submissions with the same content?\n\n* When the Activity is a submission this defaults to **true**\n* When the Activity is a comment it is ignored (not relevant)",
"type": "boolean"
},
"window": {
@@ -1936,7 +1936,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},

View File

@@ -640,7 +640,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},
@@ -1122,7 +1122,7 @@
},
"useSubmissionAsReference": {
"default": true,
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
"description": "When Activity is a submission should we only include activities that are other submissions with the same content?\n\n* When the Activity is a submission this defaults to **true**\n* When the Activity is a comment it is ignored (not relevant)",
"type": "boolean"
},
"window": {
@@ -1910,7 +1910,7 @@
"type": "boolean"
},
"reports": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 2` => greater than 2 total reports\n\nDefaults to TOTAL reports on an Activity. Suffix the value with the report type to check that type:\n\n* EX `> 3 mod` => greater than 3 mod reports\n* EX `>= 1 user` => greater than 1 user report",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
"type": "string"
},

View File

@@ -63,6 +63,7 @@ export interface runCheckOptions {
delayUntil?: number,
dryRun?: boolean,
refresh?: boolean,
force?: boolean,
}
export interface CheckTask {
@@ -272,6 +273,7 @@ export class Manager extends EventEmitter {
if(this.modPermissions !== undefined) {
return this.modPermissions as string[];
}
this.logger.debug('Retrieving mod permissions for bot');
const userInfo = parseRedditEntity(this.botName, 'user');
const mods = this.subreddit.getModerators({name: userInfo.name});
// @ts-ignore
@@ -358,7 +360,6 @@ export class Manager extends EventEmitter {
}
protected async parseConfigurationFromObject(configObj: object) {
await this.getModPermissions();
try {
const configBuilder = new ConfigBuilder({logger: this.logger});
const validJson = configBuilder.validateJson(configObj);
@@ -581,6 +582,18 @@ export class Manager extends EventEmitter {
const checks = checkType === 'Comment' ? this.commentChecks : this.submissionChecks;
let item = activity;
const itemId = await item.id;
if(await this.resources.hasRecentSelf(item)) {
const {force = false} = options || {};
let recentMsg = `Found in Activities recently (last ${this.resources.selfTTL} seconds) modified/created by this bot`;
if(force) {
this.logger.debug(`${recentMsg} but will run anyway because "force" option was true.`);
} else {
this.logger.debug(`${recentMsg} so will skip running.`);
return;
}
}
let allRuleResults: RuleResult[] = [];
const itemIdentifier = `${checkType === 'Submission' ? 'SUB' : 'COM'} ${itemId}`;
this.currentLabels = [itemIdentifier];
@@ -690,6 +703,7 @@ export class Manager extends EventEmitter {
if (e.logged !== true) {
this.logger.warn(`Running rules for Check ${check.name} failed due to uncaught exception`, e);
}
this.emit('error', e);
}
if (triggered) {
@@ -702,6 +716,11 @@ export class Manager extends EventEmitter {
actionedEvent.ruleSummary = resultsSummary(currentResults, check.condition);
}
runActions = await check.runActions(item, currentResults.filter(x => x.triggered), dryRun);
// we only can about report and comment actions since those can produce items for newComm and modqueue
const recentCandidates = runActions.filter(x => ['report','comment'].includes(x.kind.toLocaleLowerCase())).map(x => x.touchedEntities === undefined ? [] : x.touchedEntities).flat();
for(const recent of recentCandidates) {
await this.resources.setRecentSelf(recent as (Submission|Comment));
}
actionsRun = runActions.length;
if(check.notifyOnTrigger) {

View File

@@ -5,7 +5,7 @@ import {PollConfiguration} from "snoostorm/out/util/Poll";
import {ClearProcessedOptions, DEFAULT_POLLING_INTERVAL} from "../Common/interfaces";
import dayjs, {Dayjs} from "dayjs";
import { Duration } from "dayjs/plugin/duration";
import {parseDuration, setRandomInterval, sleep} from "../util";
import {parseDuration, random} from "../util";
type Awaitable<T> = Promise<T> | T;
@@ -50,35 +50,10 @@ export class SPoll<T extends object> extends Poll<T> {
clearInterval(this.interval);
}
startInterval = () => {
this.running = true;
this.randInterval = setRandomInterval((function (self) {
createInterval = () => {
this.interval = setTimeout((function (self) {
return async () => {
try {
// DEBUGGING
//
// Removing processed clearing to see if it fixes weird, duplicate/delayed comment processing behavior
//
// clear the tracked, processed activity ids after a set period or number of activities have been processed
// because when RCB is long-running and has streams from high-volume subreddits this list never gets smaller...
// so clear if after time period
// if ((self.clearProcessedAfter !== undefined && dayjs().isSameOrAfter(self.clearProcessedAfter))
// // or clear if processed list is larger than defined max allowable size (default setting, 2 * polling option limit)
// || (self.clearProcessedSize !== undefined && self.processed.size >= self.clearProcessedSize)) {
// if (self.retainProcessed === 0) {
// self.processed = new Set();
// } else {
// // retain some processed so we have continuity between processed list resets -- this is default behavior and retains polling option limit # of activities
// // we can slice from the set here because ID order is guaranteed for Set object so list is oldest -> newest
// // -- retain last LIMIT number of activities (or all if retain # is larger than list due to user config error)
// self.processed = new Set(Array.from(self.processed).slice(Math.max(0, self.processed.size - self.retainProcessed)));
// }
// // reset time interval if there is one
// if (self.clearProcessedAfter !== undefined && self.clearProcessedDuration !== undefined) {
// self.clearProcessedAfter = dayjs().add(self.clearProcessedDuration.asSeconds(), 's');
// }
// }
const batch = await self.getter();
const newItems: T[] = [];
for (const item of batch) {
@@ -93,19 +68,23 @@ export class SPoll<T extends object> extends Poll<T> {
// Emit the new listing of all new items
self.emit("listing", newItems);
// if everything succeeded then create a new timeout
self.createInterval();
} catch (err: any) {
self.emit('error', err);
self.end();
}
}
})(this), this.frequency - 1, this.frequency + 1);
})(this), random(this.frequency - 1, this.frequency + 1));
}
startInterval = () => {
this.running = true;
this.createInterval();
}
end = () => {
this.running = false;
if(this.randInterval !== undefined) {
this.randInterval.clear();
}
super.end();
}
}

View File

@@ -90,6 +90,7 @@ export class SubredditResources {
protected submissionTTL: number | false = cacheTTLDefaults.submissionTTL;
protected commentTTL: number | false = cacheTTLDefaults.commentTTL;
protected filterCriteriaTTL: number | false = cacheTTLDefaults.filterCriteriaTTL;
public selfTTL: number | false = cacheTTLDefaults.selfTTL;
name: string;
protected logger: Logger;
userNotes: UserNotes;
@@ -119,6 +120,7 @@ export class SubredditResources {
authorTTL,
wikiTTL,
filterCriteriaTTL,
selfTTL,
submissionTTL,
commentTTL,
subredditTTL,
@@ -144,6 +146,7 @@ export class SubredditResources {
this.subredditTTL = subredditTTL === true ? 0 : subredditTTL;
this.wikiTTL = wikiTTL === true ? 0 : wikiTTL;
this.filterCriteriaTTL = filterCriteriaTTL === true ? 0 : filterCriteriaTTL;
this.selfTTL = selfTTL === true ? 0 : selfTTL;
this.subreddit = subreddit;
this.thirdPartyCredentials = thirdPartyCredentials;
this.name = name;
@@ -391,6 +394,50 @@ export class SubredditResources {
}
}
async hasActivity(item: Submission | Comment) {
const hash = asSubmission(item) ? `sub-${item.name}` : `comm-${item.name}`;
const res = await this.cache.get(hash);
return res !== undefined && res !== null;
}
// @ts-ignore
async getRecentSelf(item: Submission | Comment): Promise<(Submission | Comment | undefined)> {
const hash = asSubmission(item) ? `sub-recentSelf-${item.name}` : `comm-recentSelf-${item.name}`;
const res = await this.cache.get(hash);
if(res === null) {
return undefined;
}
return res as (Submission | Comment | undefined);
}
async setRecentSelf(item: Submission | Comment) {
if(this.selfTTL !== false) {
const hash = asSubmission(item) ? `sub-recentSelf-${item.name}` : `comm-recentSelf-${item.name}`;
// @ts-ignore
await this.cache.set(hash, item, {ttl: this.selfTTL});
}
return;
}
/**
* Returns true if the activity being checked was recently acted on/created by the bot and has not changed since that time
* */
async hasRecentSelf(item: Submission | Comment) {
const recent = await this.getRecentSelf(item) as (Submission | Comment | undefined);
if (recent !== undefined) {
return item.num_reports === recent.num_reports;
// can't really used edited since its only ever updated once with no timestamp
// if(item.num_reports !== recent.num_reports) {
// return false;
// }
// if(!asSubmission(item)) {
// return item.edited === recent.edited;
// }
// return true;
}
return false;
}
// @ts-ignore
async getSubreddit(item: Submission | Comment) {
try {
@@ -836,13 +883,30 @@ export class SubredditResources {
break;
case 'reports':
if (!item.can_mod_post) {
log.debug(`Cannot test for reports on Activity in a subreddit bot account is not a moderato Activist. Skipping criteria...`);
log.debug(`Cannot test for reports on Activity in a subreddit bot account is not a moderator of. Skipping criteria...`);
break;
}
const reportCompare = parseGenericValueComparison(crit[k] as string);
if(!comparisonTextOp(item.num_reports, reportCompare.operator, reportCompare.value)) {
let reportType = 'total';
if(reportCompare.extra !== undefined && reportCompare.extra.trim() !== '') {
const requestedType = reportCompare.extra.toLocaleLowerCase().trim();
if(requestedType.includes('mod')) {
reportType = 'mod';
} else if(requestedType.includes('user')) {
reportType = 'user';
} else {
log.warn(`Did not recognize the report type "${requestedType}" -- can only use "mod" or "user". Will default to TOTAL reports`);
}
}
let reportNum = item.num_reports;
if(reportType === 'user') {
reportNum = item.user_reports.length;
} else {
reportNum = item.mod_reports.length;
}
if(!comparisonTextOp(reportNum, reportCompare.operator, reportCompare.value)) {
// @ts-ignore
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item.num_reports}`)
log.debug(`Failed: Expected => ${k}:${crit[k]} ${reportType} reports | Found => ${k}:${reportNum} ${reportType} reports`)
return false
}
break;
@@ -1064,6 +1128,7 @@ export class BotResourcesManager {
submissionTTL,
subredditTTL,
filterCriteriaTTL,
selfTTL,
provider,
actionedEventsMax,
actionedEventsDefault,
@@ -1080,7 +1145,7 @@ export class BotResourcesManager {
this.cacheHash = objectHash.sha1(relevantCacheSettings);
this.defaultCacheConfig = caching;
this.defaultThirdPartyCredentials = thirdParty;
this.ttlDefaults = {authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL, subredditTTL};
this.ttlDefaults = {authorTTL, userNotesTTL, wikiTTL, commentTTL, submissionTTL, filterCriteriaTTL, subredditTTL, selfTTL};
const options = provider;
this.cacheType = options.store;

View File

@@ -1,4 +1,4 @@
import {StatusCodeError} from "../Common/interfaces";
import {StatusCodeError, RequestError} from "../Common/interfaces";
export const isRateLimitError = (err: any) => {
@@ -16,3 +16,7 @@ export const isScopeError = (err: any): boolean => {
export const isStatusError = (err: any): err is StatusCodeError => {
return typeof err === 'object' && err.name === 'StatusCodeError' && err.response !== undefined;
}
export const isRequestError = (err: any): err is RequestError => {
return typeof err === 'object' && err.name === 'RequestError' && err.response !== undefined;
}

View File

@@ -352,6 +352,9 @@ const webClient = async (options: OperatorConfig) => {
msg = `${msg}. ${botAddResult.stored === false ? 'Additionally, the bot was not stored in config so the operator will need to add it manually to persist after a restart.' : ''}`;
}
data.addResult = msg;
// @ts-ignore
req.session.destroy();
req.logout();
}
}
return res.render('callback', data);
@@ -664,7 +667,7 @@ const webClient = async (options: OperatorConfig) => {
const user = req.user as Express.User;
const isOperator = instance.operators.includes(user.name);
const canAccessBot = isOperator || intersect(user.subreddits, botInstance.subreddits).length > 0;
const canAccessBot = isOperator || intersect(user.subreddits, botInstance.subreddits.map(x => x.replace(/\\*r\/*/,''))).length > 0;
if (!user.isOperator && !canAccessBot) {
return res.status(404).render('error', {error: msg});
}

View File

@@ -70,6 +70,9 @@ const action = async (req: express.Request, res: express.Response) => {
await manager.firehose.push({
checkType: a instanceof Submission ? 'Submission' : 'Comment',
activity: a,
options: {
force: true,
}
});
}
} else {
@@ -78,6 +81,9 @@ const action = async (req: express.Request, res: express.Response) => {
await manager.firehose.push({
checkType: a instanceof Submission ? 'Submission' : 'Comment',
activity: a,
options: {
force: true
}
});
}
}

View File

@@ -127,7 +127,7 @@ const action = async (req: Request, res: Response) => {
// will run dryrun if specified or if running activity on subreddit it does not belong to
const dr: boolean | undefined = (dryRun || manager.subreddit.display_name !== sub) ? true : undefined;
manager.logger.info(`/u/${userName} running${dr === true ? ' DRY RUN ' : ' '}check on${manager.subreddit.display_name !== sub ? ' FOREIGN ACTIVITY ' : ' '}${url}`);
await manager.runChecks(activity instanceof Submission ? 'Submission' : 'Comment', activity, {dryRun: dr})
await manager.runChecks(activity instanceof Submission ? 'Submission' : 'Comment', activity, {dryRun: dr, force: true})
}
res.send('OK');
};

View File

@@ -41,7 +41,10 @@ const status = () => {
if(req.serverBot !== undefined) {
bots = [req.serverBot];
} else {
bots = (req.user as Express.User).isOperator ? req.botApp.bots : req.botApp.bots.filter(x => intersect(req.user?.subreddits as string[], x.subManagers.map(y => y.subreddit.display_name)));
bots = (req.user as Express.User).isOperator ? req.botApp.bots : req.botApp.bots.filter(x => {
const i = intersect(req.user?.subreddits as string[], x.subManagers.map(y => y.subreddit.display_name));
return i.length > 0;
});
}
const botResponses: BotStatusResponse[] = [];
for(const b of bots) {
@@ -89,6 +92,9 @@ const status = () => {
if(m === undefined) {
continue;
}
if(!(req.user as Express.User).isOperator && !(req.user?.subreddits as string[]).includes(m.subreddit.display_name)) {
continue;
}
const sd = {
name: s,
//linkName: s.replace(/\W/g, ''),

View File

@@ -206,24 +206,20 @@ const rcbServer = async function (options: OperatorConfig) {
server.deleteAsync('/bot/invite', ...deleteInviteRoute);
const initBot = async (causedBy: Invokee = 'system') => {
if(app !== undefined) {
if (app !== undefined) {
logger.info('A bot instance already exists. Attempting to stop event/queue processing first before building new bot.');
await app.destroy(causedBy);
}
const newApp = new App(options);
if(newApp.error === undefined) {
try {
await newApp.initBots(causedBy);
} catch (err: any) {
if(newApp.error === undefined) {
newApp.error = err.message;
}
logger.error('Server is still ONLINE but bot cannot recover from this error and must be re-built');
if(!err.logged || !(err instanceof LoggedError)) {
logger.error(err);
}
newApp.initBots(causedBy).catch((err: any) => {
if (newApp.error === undefined) {
newApp.error = err.message;
}
}
logger.error('Server is still ONLINE but bot cannot recover from this error and must be re-built');
if (!err.logged || !(err instanceof LoggedError)) {
logger.error(err);
}
});
return newApp;
}

View File

@@ -128,6 +128,12 @@
class="font-mono font-semibold">modcontributors</span> for the bot to
ban/mute users in the subreddits it moderates</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="flair" name="flair"
checked>
<label for="flair"><span class="font-mono font-semibold">flair</span> for the bot
to select subreddit flairs.</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="modposts" name="modposts"
checked>

View File

@@ -86,6 +86,11 @@
class="font-mono font-semibold">modcontributors</span> for the bot to
ban/mute users in the subreddits it moderates</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="flair" name="flair" disabled>
<label for="flair"><span class="font-mono font-semibold">flair</span> for the bot
to select subreddit flairs.</label>
</div>
<div>
<input class="permissionToggle" type="checkbox" id="modposts" name="modposts" disabled>
<label for="modposts"><span class="font-mono font-semibold">modposts</span> for the bot

View File

@@ -58,6 +58,7 @@ import {SetRandomInterval} from "./Common/types";
import stringSimilarity from 'string-similarity';
import calculateCosineSimilarity from "./Utils/StringMatching/CosineSimilarity";
import levenSimilarity from "./Utils/StringMatching/levenSimilarity";
import {isRequestError, isStatusError} from "./Utils/Errors";
//import {ResembleSingleCallbackComparisonResult} from "resemblejs";
// want to guess how many concurrent image comparisons we should be doing
@@ -684,37 +685,40 @@ export const createRetryHandler = (opts: RetryOptions, logger: Logger) => {
lastErrorAt = dayjs();
if(err.name === 'RequestError' || err.name === 'StatusCodeError') {
const redditApiError = isRequestError(err) || isStatusError(err);
if(redditApiError) {
if (err.statusCode === undefined || ([401, 500, 503, 502, 504, 522].includes(err.statusCode))) {
timeoutCount++;
let msg = `Error occurred while making a request to Reddit (${timeoutCount}/${maxRequestRetry+1} in ${clearRetryCountAfter} minutes).`;
if (timeoutCount > maxRequestRetry) {
logger.error(`Reddit request error retries (${timeoutCount}) exceeded max allowed (${maxRequestRetry})`);
logger.error(`${msg} Exceeded max allowed.`);
return false;
}
if(waitOnRetry) {
// exponential backoff
const ms = (Math.pow(2, timeoutCount - 1) + (Math.random() - 0.3) + 1) * 1000;
logger.warn(`Error occurred while making a request to Reddit (${timeoutCount} in 3 minutes). Will wait ${formatNumber(ms / 1000)} seconds before retrying`);
logger.warn(`${msg} Will wait ${formatNumber(ms / 1000)} seconds before retrying.`);
await sleep(ms);
}
return true;
} else {
return false;
}
} else {
// linear backoff
otherRetryCount++;
if (maxOtherRetry < otherRetryCount) {
return false;
}
if(waitOnRetry) {
const ms = (4 * 1000) * otherRetryCount;
logger.warn(`Non-request error occurred. Will wait ${formatNumber(ms / 1000)} seconds before retrying`);
await sleep(ms);
}
return true;
// if it's a request error but not a known "oh probably just a reddit blip" status code treat it as other, which should usually have a lower retry max
}
// linear backoff
otherRetryCount++;
let msg = redditApiError ? `Error occurred while making a request to Reddit (${otherRetryCount}/${maxOtherRetry} in ${clearRetryCountAfter} minutes) but it was NOT a well-known "reddit blip" error.` : `Non-request error occurred (${otherRetryCount}/${maxOtherRetry} in ${clearRetryCountAfter} minutes).`;
if (maxOtherRetry < otherRetryCount) {
logger.warn(`${msg} Exceeded max allowed.`);
return false;
}
if(waitOnRetry) {
const ms = (4 * 1000) * otherRetryCount;
logger.warn(`${msg} Will wait ${formatNumber(ms / 1000)} seconds before retrying`);
await sleep(ms);
}
return true;
}
}
@@ -1559,40 +1563,6 @@ export const random = (min: number, max: number): number => (
Math.floor(Math.random() * (max - min + 1)) + min
);
// copy-pasting interval function from this project because bad tooling on the author's side means A WHOLE OTHER typescript lib is always downloaded just for this one function
/**
* Repeatedly calls a function with a random time delay between each call.
*
* @param intervalFunction - A function to be executed at random times between `minDelay` and
* `maxDelay`. The function is not passed any arguments, and no return value is expected.
* @param minDelay - The minimum amount of time, in milliseconds (thousandths of a second), the
* timer should delay in between executions of `intervalFunction`.
* @param maxDelay - The maximum amount of time, in milliseconds (thousandths of a second), the
* timer should delay in between executions of `intervalFunction`.
*
* Borrowed from https://github.com/jabacchetta/set-random-interval/blob/master/src/index.ts
*/
export const setRandomInterval: SetRandomInterval = (intervalFunction, minDelay = 0, maxDelay = 0) => {
let timeout: ReturnType<typeof setTimeout>;
const runInterval = (): void => {
timeout = globalThis.setTimeout(() => {
intervalFunction();
runInterval();
}, random(minDelay, maxDelay));
};
runInterval();
return {
clear(): void {
clearTimeout(timeout);
},
};
};
/**
* Naively detect if a string is most likely json5
*