Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e07b8cc291 | ||
|
|
80fabeac54 | ||
|
|
c001be9abf | ||
|
|
639a542fb2 | ||
|
|
9299258de0 | ||
|
|
59f8ac6dd4 | ||
|
|
f16155bb1f | ||
|
|
e2d2f73bb3 | ||
|
|
9ca5d6c8c2 | ||
|
|
d8f673bd26 | ||
|
|
7e2068d82a | ||
|
|
176611dbf3 | ||
|
|
3d99406f33 | ||
|
|
ab355977ba | ||
|
|
8667fcdef3 | ||
|
|
ec20445772 | ||
|
|
0293928a99 | ||
|
|
b56d6dbe7c | ||
|
|
42d269e28d | ||
|
|
8f60a1da53 | ||
|
|
f511be7c33 | ||
|
|
ebb426e696 |
BIN
docs/screenshots/actionsEvents.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
docs/screenshots/botOperations.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
docs/screenshots/config/config.jpg
Normal file
|
After Width: | Height: | Size: 75 KiB |
BIN
docs/screenshots/config/configUpdate.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/screenshots/config/correctness.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
docs/screenshots/config/enable.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/screenshots/config/errors.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
docs/screenshots/config/save.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
docs/screenshots/config/syntax.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/screenshots/logs.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
docs/screenshots/runInput.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
30
docs/webInterface.md
Normal file
@@ -0,0 +1,30 @@
|
||||
## Editing/Updating Your Config
|
||||
|
||||
* Open the editor for your subreddit
|
||||
* In the web dashboard \-> r/YourSubreddit \-> Config -> **View** [(here)](/docs/screenshots/config/config.jpg)
|
||||
* Follow the directions on the [link at the top of the window](/docs/screenshots/config/save.png) to enable config editing using your moderator account
|
||||
* After enabling editing just click "save" at any time to save your config
|
||||
* After you have added/edited your config the bot will detect changes within 5 minutes or you can manually trigger it by clicking **Update**
|
||||
|
||||
## General Config (Editor) Tips
|
||||
|
||||
* The editor will automatically validate your [syntax (formatting)](/docs/screenshots/config/syntax.png) and [config correctness](/docs/screenshots/config/correctness.png) (property names, required properties, etc.)
|
||||
* These show up as squiggly lines like in Microsoft Word and as a [list at the bottom of the editor](/docs/screenshots/config/errors.png)
|
||||
* In your config all **Checks** and **Actions** have two properties that control how they behave:
|
||||
* [**Enable**](/docs/screenshots/config/enable.png) (defaults to `enable: true`) -- Determines if the check or action is run, at all
|
||||
* **Dryrun** (defaults to `dryRun: false`) -- When `true` the check or action will run but any **Actions** that may be triggered will "pretend" to execute but not actually talk to the Reddit API.
|
||||
* Use `dryRun` to test your config without the bot making any changes on reddit
|
||||
* When starting out with a new config it is recommended running the bot with remove/ban actions **disabled**
|
||||
* Use `report` actions to get reports in your modqueue from the bot that describe what it detected and what it would do about it
|
||||
* Once the bot is behaving as desired (no false positives or weird behavior) destructive actions can be enabled or turned off of dryrun
|
||||
|
||||
## Web Dashboard Tips
|
||||
|
||||
* Use the [**Overview** section](/docs/screenshots/botOperations.png) to control the bot at a high-level
|
||||
* You can **manually run** the bot on any activity (comment/submission) by pasting its permalink into the [input field below the Overview section](/docs/screenshots/runInput.png) and hitting one of the **run buttons**
|
||||
* **Dry run** will make the bot run on the activity but it will only **pretend** to run actions, if triggered. This is super useful for testing your config without consequences
|
||||
* **Run** will do everything
|
||||
* All of the bot's activity is shown in real-time in the [log section](/docs/screenshots/logs.png)
|
||||
* This will output the results of all run checks/rules and any actions that run
|
||||
* You can view summaries of all activities that triggered a check (had actions run) by clicking on [Actioned Events](/docs/screenshots/actionsEvents.png)
|
||||
* This includes activities run with dry run
|
||||
62
package-lock.json
generated
@@ -34,6 +34,7 @@
|
||||
"express-socket.io-session": "^1.3.5",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^6.4.6",
|
||||
"globrex": "^0.1.2",
|
||||
"got": "^11.8.2",
|
||||
"he": "^1.2.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
@@ -80,6 +81,7 @@
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/express-socket.io-session": "^1.3.6",
|
||||
"@types/globrex": "^0.1.1",
|
||||
"@types/he": "^1.1.1",
|
||||
"@types/http-proxy": "^1.17.7",
|
||||
"@types/js-yaml": "^4.0.1",
|
||||
@@ -360,6 +362,15 @@
|
||||
"@types/redis": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cache-manager-redis-store/node_modules/@types/redis": {
|
||||
"version": "2.8.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
|
||||
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cacheable-request": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.2.tgz",
|
||||
@@ -456,6 +467,12 @@
|
||||
"@types/socket.io": "2.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/globrex": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/globrex/-/globrex-0.1.1.tgz",
|
||||
"integrity": "sha512-bce8X5Yb8l8ou2VDaEG8CYY1p6NynmswkaasO1pdAzFASKJ43sjf9MQdVH6VmKNG2bPEEmvI5onJJSH+1qOMOA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/he": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.1.2.tgz",
|
||||
@@ -607,15 +624,6 @@
|
||||
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/redis": {
|
||||
"version": "2.8.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
|
||||
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/responselike": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
|
||||
@@ -2147,6 +2155,11 @@
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
|
||||
},
|
||||
"node_modules/google-auth-library": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.11.0.tgz",
|
||||
@@ -4931,6 +4944,17 @@
|
||||
"requires": {
|
||||
"@types/cache-manager": "*",
|
||||
"@types/redis": "^2.8.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/redis": {
|
||||
"version": "2.8.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
|
||||
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/cacheable-request": {
|
||||
@@ -5029,6 +5053,12 @@
|
||||
"@types/socket.io": "2.1.13"
|
||||
}
|
||||
},
|
||||
"@types/globrex": {
|
||||
"version": "0.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/globrex/-/globrex-0.1.1.tgz",
|
||||
"integrity": "sha512-bce8X5Yb8l8ou2VDaEG8CYY1p6NynmswkaasO1pdAzFASKJ43sjf9MQdVH6VmKNG2bPEEmvI5onJJSH+1qOMOA==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/he": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/he/-/he-1.1.2.tgz",
|
||||
@@ -5180,15 +5210,6 @@
|
||||
"integrity": "sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==",
|
||||
"dev": true
|
||||
},
|
||||
"@types/redis": {
|
||||
"version": "2.8.32",
|
||||
"resolved": "https://registry.npmjs.org/@types/redis/-/redis-2.8.32.tgz",
|
||||
"integrity": "sha512-7jkMKxcGq9p242exlbsVzuJb57KqHRhNl4dHoQu2Y5v9bCAbtIXXH0R3HleSQW4CTOqpHIYUW3t6tpUj4BVQ+w==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/responselike": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/responselike/-/responselike-1.0.0.tgz",
|
||||
@@ -6401,6 +6422,11 @@
|
||||
"path-is-absolute": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"globrex": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz",
|
||||
"integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg=="
|
||||
},
|
||||
"google-auth-library": {
|
||||
"version": "7.11.0",
|
||||
"resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-7.11.0.tgz",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"express-socket.io-session": "^1.3.5",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fuse.js": "^6.4.6",
|
||||
"globrex": "^0.1.2",
|
||||
"got": "^11.8.2",
|
||||
"he": "^1.2.0",
|
||||
"http-proxy": "^1.18.1",
|
||||
@@ -95,6 +96,7 @@
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-session": "^1.17.4",
|
||||
"@types/express-socket.io-session": "^1.3.6",
|
||||
"@types/globrex": "^0.1.1",
|
||||
"@types/he": "^1.1.1",
|
||||
"@types/http-proxy": "^1.17.7",
|
||||
"@types/js-yaml": "^4.0.1",
|
||||
|
||||
@@ -36,7 +36,7 @@ export class ApproveAction extends Action {
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (item.approved) {
|
||||
if (targetItem.approved) {
|
||||
const msg = `${target === 'self' ? 'Item' : 'Comment\'s parent Submission'} is already approved`;
|
||||
this.logger.warn(msg);
|
||||
return {
|
||||
@@ -54,6 +54,16 @@ export class ApproveAction extends Action {
|
||||
}
|
||||
// @ts-ignore
|
||||
touchedEntities.push(await targetItem.approve());
|
||||
|
||||
if(target === 'self') {
|
||||
// @ts-ignore
|
||||
item.approved = true;
|
||||
await this.resources.resetCacheForItem(item);
|
||||
} else if(await this.resources.hasActivity(targetItem)) {
|
||||
// @ts-ignore
|
||||
targetItem.approved = true;
|
||||
await this.resources.resetCacheForItem(targetItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ export class LockAction extends Action {
|
||||
//snoowrap typing issue, thinks comments can't be locked
|
||||
// @ts-ignore
|
||||
await item.lock();
|
||||
// @ts-ignore
|
||||
item.locked = true;
|
||||
await this.resources.resetCacheForItem(item);
|
||||
touchedEntities.push(item);
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -4,6 +4,8 @@ import Snoowrap, {Comment, Submission} from "snoowrap";
|
||||
import {RuleResult} from "../Rule";
|
||||
import {activityIsRemoved} from "../Utils/SnoowrapUtils";
|
||||
import {ActionProcessResult} from "../Common/interfaces";
|
||||
import dayjs from "dayjs";
|
||||
import {isSubmission} from "../util";
|
||||
|
||||
export class RemoveAction extends Action {
|
||||
spam: boolean;
|
||||
@@ -26,11 +28,7 @@ export class RemoveAction extends Action {
|
||||
// issue with snoowrap typings, doesn't think prop exists on Submission
|
||||
// @ts-ignore
|
||||
if (activityIsRemoved(item)) {
|
||||
return {
|
||||
dryRun,
|
||||
success: false,
|
||||
result: 'Item is already removed',
|
||||
}
|
||||
this.logger.warn('It looks like this Item is already removed!');
|
||||
}
|
||||
if (this.spam) {
|
||||
this.logger.verbose('Marking as spam on removal');
|
||||
@@ -38,6 +36,13 @@ export class RemoveAction extends Action {
|
||||
if (!dryRun) {
|
||||
// @ts-ignore
|
||||
await item.remove({spam: this.spam});
|
||||
item.banned_at_utc = dayjs().unix();
|
||||
item.spam = this.spam;
|
||||
if(!isSubmission(item)) {
|
||||
// @ts-ignore
|
||||
item.removed = true;
|
||||
}
|
||||
await this.resources.resetCacheForItem(item);
|
||||
touchedEntities.push(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ export class ReportAction extends Action {
|
||||
await item.report({reason: truncatedContent});
|
||||
// due to reddit not updating this in response (maybe)?? just increment stale activity
|
||||
item.num_reports++;
|
||||
await this.resources.resetCacheForItem(item);
|
||||
touchedEntities.push(item);
|
||||
}
|
||||
|
||||
|
||||
@@ -33,16 +33,26 @@ export class FlairAction extends Action {
|
||||
if(this.css !== '') {
|
||||
flairParts.push(`CSS: ${this.css}`);
|
||||
}
|
||||
if(this.flair_template_id !== '') {
|
||||
flairParts.push(`Template: ${this.flair_template_id}`);
|
||||
}
|
||||
const flairSummary = flairParts.length === 0 ? 'No flair (unflaired)' : flairParts.join(' | ');
|
||||
this.logger.verbose(flairSummary);
|
||||
if (item instanceof Submission) {
|
||||
if(!this.dryRun) {
|
||||
if (this.flair_template_id) {
|
||||
await item.selectFlair({flair_template_id: this.flair_template_id}).then(() => {});
|
||||
// typings are wrong for this function, flair_template_id should be accepted
|
||||
// assignFlair uses /api/flair (mod endpoint)
|
||||
// selectFlair uses /api/selectflair (self endpoint for user to choose their own flair for submission)
|
||||
// @ts-ignore
|
||||
await item.assignFlair({flair_template_id: this.flair_template_id}).then(() => {});
|
||||
item.link_flair_template_id = this.flair_template_id;
|
||||
} else {
|
||||
await item.assignFlair({text: this.text, cssClass: this.css}).then(() => {});
|
||||
item.link_flair_css_class = this.css;
|
||||
item.link_flair_text = this.text;
|
||||
}
|
||||
|
||||
await this.resources.resetCacheForItem(item);
|
||||
}
|
||||
} else {
|
||||
this.logger.warn('Cannot flair Comment');
|
||||
|
||||
@@ -50,6 +50,7 @@ export class UserFlairAction extends Action {
|
||||
flairTemplateId: this.flair_template_id,
|
||||
username: item.author.name,
|
||||
});
|
||||
item.author_flair_template_id = this.flair_template_id
|
||||
} catch (err: any) {
|
||||
this.logger.error('Either the flair template ID is incorrect or you do not have permission to access it.');
|
||||
throw err;
|
||||
@@ -57,6 +58,9 @@ export class UserFlairAction extends Action {
|
||||
} else if (this.text === undefined && this.css === undefined) {
|
||||
// @ts-ignore
|
||||
await item.subreddit.deleteUserFlair(item.author.name);
|
||||
item.author_flair_css_class = null;
|
||||
item.author_flair_text = null;
|
||||
item.author_flair_template_id = null;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
await item.author.assignFlair({
|
||||
@@ -64,7 +68,11 @@ export class UserFlairAction extends Action {
|
||||
cssClass: this.css,
|
||||
text: this.text,
|
||||
});
|
||||
item.author_flair_text = this.text ?? null;
|
||||
item.author_flair_css_class = this.css ?? null;
|
||||
}
|
||||
await this.resources.resetCacheForItem(item);
|
||||
await this.resources.resetCacheForItem(item.author);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import Author, {AuthorOptions} from "../Author/Author";
|
||||
import {mergeArr} from "../util";
|
||||
import LoggedError from "../Utils/LoggedError";
|
||||
import {ExtendedSnoowrap} from '../Utils/SnoowrapClients';
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
export abstract class Action {
|
||||
name?: string;
|
||||
@@ -86,7 +87,8 @@ export abstract class Action {
|
||||
return {...actRes, ...results};
|
||||
} catch (err: any) {
|
||||
if(!(err instanceof LoggedError)) {
|
||||
this.logger.error(`Encountered error while running`, err);
|
||||
const actionError = new ErrorWithCause('Action did not run successfully due to unexpected error', {cause: err});
|
||||
this.logger.error(actionError);
|
||||
}
|
||||
actRes.success = false;
|
||||
actRes.result = err.message;
|
||||
|
||||
@@ -949,6 +949,10 @@ export interface SubmissionState extends ActivityState {
|
||||
link_flair_text?: string | string[]
|
||||
link_flair_css_class?: string | string[]
|
||||
flairTemplate?: string | string[]
|
||||
/**
|
||||
* Is the submission a reddit-hosted image or video?
|
||||
* */
|
||||
isRedditMediaDomain?: boolean
|
||||
}
|
||||
|
||||
// properties calculated/derived by CM -- not provided as plain values by reddit
|
||||
|
||||
@@ -666,9 +666,13 @@ export const parseOperatorConfigFromSources = async (args: any): Promise<[Operat
|
||||
defaultBotInstance.caching = configFromFile.caching;
|
||||
}
|
||||
|
||||
let botInstances = [];
|
||||
let botInstances: BotInstanceJsonConfig[] = [];
|
||||
if (botInstancesFromFile.length === 0) {
|
||||
botInstances = [defaultBotInstance];
|
||||
// only add default bot if user supplied any credentials
|
||||
// otherwise its most likely just default, empty settings
|
||||
if(defaultBotInstance.credentials !== undefined) {
|
||||
botInstances = [defaultBotInstance];
|
||||
}
|
||||
} else {
|
||||
botInstances = botInstancesFromFile.map(x => merge.all([defaultBotInstance, x], {arrayMerge: overwriteMerge}));
|
||||
}
|
||||
@@ -772,6 +776,10 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
...fileRest
|
||||
} = file;
|
||||
|
||||
const defaultWebCredentials = {
|
||||
redirectUri: 'http://localhost:8085/callback'
|
||||
};
|
||||
|
||||
|
||||
const config: OperatorConfig = {
|
||||
mode,
|
||||
@@ -811,7 +819,7 @@ export const buildOperatorConfigWithDefaults = (data: OperatorJsonConfig): Opera
|
||||
},
|
||||
maxLogs,
|
||||
clients: clients === undefined ? [{host: 'localhost:8095', secret: apiSecret}] : clients,
|
||||
credentials: webCredentials as RequiredWebRedditCredentials,
|
||||
credentials: {...defaultWebCredentials, ...webCredentials} as RequiredWebRedditCredentials,
|
||||
operators: operators || defaultOperators,
|
||||
},
|
||||
api: {
|
||||
|
||||
@@ -95,6 +95,15 @@ export interface RegexCriteria {
|
||||
* */
|
||||
totalMatchThreshold?: string,
|
||||
|
||||
/**
|
||||
* When `true` the Activity being checked MUST pass the `matchThreshold` before the Rule considers any history
|
||||
*
|
||||
* For use with `activityMatchThreshold`/`totalMatchThreshold` -- useful to conserve API calls
|
||||
*
|
||||
* @default false
|
||||
* */
|
||||
mustMatchCurrent?: boolean
|
||||
|
||||
window?: ActivityWindowType
|
||||
}
|
||||
|
||||
@@ -140,6 +149,7 @@ export class RegexRule extends Rule {
|
||||
matchThreshold = '> 0',
|
||||
activityMatchThreshold = '> 0',
|
||||
totalMatchThreshold = null,
|
||||
mustMatchCurrent = false,
|
||||
window,
|
||||
} = criteria;
|
||||
|
||||
@@ -184,6 +194,8 @@ export class RegexRule extends Rule {
|
||||
if (singleMatched) {
|
||||
activitiesMatchedCount++;
|
||||
}
|
||||
const singleCriteriaPass = !mustMatchCurrent || (mustMatchCurrent && singleMatched);
|
||||
|
||||
if (activityMatchComparison !== undefined) {
|
||||
activityThresholdMet = !activityMatchComparison.isPercent && comparisonTextOp(activitiesMatchedCount, activityMatchComparison.operator, activityMatchComparison.value);
|
||||
}
|
||||
@@ -192,7 +204,7 @@ export class RegexRule extends Rule {
|
||||
}
|
||||
|
||||
let history: (Submission | Comment)[] = [];
|
||||
if ((activityThresholdMet === false || totalThresholdMet === false) && window !== undefined) {
|
||||
if ((activityThresholdMet === false || totalThresholdMet === false) && window !== undefined && singleCriteriaPass) {
|
||||
// our checking activity didn't meet threshold requirements and criteria does define window
|
||||
// leh go
|
||||
|
||||
@@ -263,7 +275,8 @@ export class RegexRule extends Rule {
|
||||
matchThreshold,
|
||||
activityMatchThreshold,
|
||||
totalMatchThreshold,
|
||||
window: humanWindow
|
||||
window: humanWindow,
|
||||
mustMatchCurrent,
|
||||
},
|
||||
matches,
|
||||
matchCount,
|
||||
|
||||
@@ -290,6 +290,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"isRedditMediaDomain": {
|
||||
"description": "Is the submission a reddit-hosted image or video?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -2413,6 +2413,11 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)(\\s+.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the Activity being checked MUST pass the `matchThreshold` before the Rule considers any history\n\nFor use with `activityMatchThreshold`/`totalMatchThreshold` -- useful to conserve API calls",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "A descriptive name that will be used in logging and be available for templating",
|
||||
"examples": [
|
||||
@@ -3508,6 +3513,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"isRedditMediaDomain": {
|
||||
"description": "Is the submission a reddit-hosted image or video?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1131,6 +1131,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"isRedditMediaDomain": {
|
||||
"description": "Is the submission a reddit-hosted image or video?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1286,6 +1286,11 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)(\\s+.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the Activity being checked MUST pass the `matchThreshold` before the Rule considers any history\n\nFor use with `activityMatchThreshold`/`totalMatchThreshold` -- useful to conserve API calls",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "A descriptive name that will be used in logging and be available for templating",
|
||||
"examples": [
|
||||
@@ -1978,6 +1983,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"isRedditMediaDomain": {
|
||||
"description": "Is the submission a reddit-hosted image or video?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -1260,6 +1260,11 @@
|
||||
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)(\\s+.*)*$",
|
||||
"type": "string"
|
||||
},
|
||||
"mustMatchCurrent": {
|
||||
"default": false,
|
||||
"description": "When `true` the Activity being checked MUST pass the `matchThreshold` before the Rule considers any history\n\nFor use with `activityMatchThreshold`/`totalMatchThreshold` -- useful to conserve API calls",
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"description": "A descriptive name that will be used in logging and be available for templating",
|
||||
"examples": [
|
||||
@@ -1952,6 +1957,10 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"isRedditMediaDomain": {
|
||||
"description": "Is the submission a reddit-hosted image or video?",
|
||||
"type": "boolean"
|
||||
},
|
||||
"is_self": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -270,15 +270,21 @@ export class Manager extends EventEmitter {
|
||||
})(this), 10000);
|
||||
}
|
||||
|
||||
protected async getModPermissions(): Promise<string[]> {
|
||||
public async getModPermissions(): Promise<string[]> {
|
||||
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
|
||||
this.modPermissions = mods[0].mod_permissions;
|
||||
try {
|
||||
const userInfo = parseRedditEntity(this.botName, 'user');
|
||||
const mods = this.subreddit.getModerators({name: userInfo.name});
|
||||
// @ts-ignore
|
||||
this.modPermissions = mods[0].mod_permissions;
|
||||
} catch (e) {
|
||||
const err = new ErrorWithCause('Unable to retrieve moderator permissions', {cause: e});
|
||||
this.logger.error(err);
|
||||
return [];
|
||||
}
|
||||
return this.modPermissions as string[];
|
||||
}
|
||||
|
||||
@@ -735,7 +741,7 @@ export class Manager extends EventEmitter {
|
||||
actionsRun = runActions.length;
|
||||
|
||||
if(check.notifyOnTrigger) {
|
||||
const ar = runActions.map(x => x.name).join(', ');
|
||||
const ar = runActions.filter(x => x.success).map(x => x.name).join(', ');
|
||||
this.notificationManager.handle('eventActioned', 'Check Triggered', `Check "${check.name}" was triggered on Event: \n\n ${ePeek} \n\n with the following actions run: ${ar}`);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -12,6 +12,7 @@ import winston, {Logger} from "winston";
|
||||
import as from 'async';
|
||||
import fetch from 'node-fetch';
|
||||
import {
|
||||
asActivity,
|
||||
asSubmission,
|
||||
buildCacheOptionsFromProvider,
|
||||
buildCachePrefix,
|
||||
@@ -19,18 +20,18 @@ import {
|
||||
compareDurationValue,
|
||||
comparisonTextOp,
|
||||
createCacheManager,
|
||||
createHistoricalStatsDisplay, FAIL,
|
||||
createHistoricalStatsDisplay, escapeRegex, FAIL,
|
||||
fetchExternalUrl, filterCriteriaSummary,
|
||||
formatNumber,
|
||||
getActivityAuthorName,
|
||||
getActivitySubredditName,
|
||||
isStrongSubredditState, isSubmission,
|
||||
isStrongSubredditState, isSubmission, isUser,
|
||||
mergeArr,
|
||||
parseDurationComparison,
|
||||
parseExternalUrl,
|
||||
parseGenericValueComparison,
|
||||
parseRedditEntity,
|
||||
parseWikiContext, PASS,
|
||||
parseRedditEntity, parseStringToRegex,
|
||||
parseWikiContext, PASS, redisScanIterator,
|
||||
shouldCacheSubredditStateCriteriaResult,
|
||||
subredditStateIsNameOnly,
|
||||
toStrongSubredditState
|
||||
@@ -69,6 +70,7 @@ import {check} from "tcp-port-used";
|
||||
import {ExtendedSnoowrap} from "../Utils/SnoowrapClients";
|
||||
import dayjs from "dayjs";
|
||||
import ImageData from "../Common/ImageData";
|
||||
import globrex from 'globrex';
|
||||
|
||||
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
|
||||
|
||||
@@ -205,24 +207,23 @@ export class SubredditResources {
|
||||
const at = await this.cache.wrap(`${this.name}-historical-allTime`, () => createHistoricalDefaults(), {ttl: 0}) as object;
|
||||
const rehydratedAt: any = {};
|
||||
for(const [k, v] of Object.entries(at)) {
|
||||
if(Array.isArray(v)) {
|
||||
const t = typeof v;
|
||||
if(t === 'number') {
|
||||
// simple number stat like eventsCheckedTotal
|
||||
rehydratedAt[k] = v;
|
||||
} else if(Array.isArray(v)) {
|
||||
// a map stat that we have data for is serialized as an array of KV pairs
|
||||
rehydratedAt[k] = new Map(v);
|
||||
} else {
|
||||
rehydratedAt[k] = v;
|
||||
}
|
||||
} else if(v === null || v === undefined || (t === 'object' && Object.keys(v).length === 0)) {
|
||||
// a map stat that was not serialized (for some reason) or serialized without any data
|
||||
rehydratedAt[k] = new Map();
|
||||
} else {
|
||||
// ???? shouldn't get here
|
||||
this.logger.warn(`Did not recognize rehydrated historical stat "${k}" of type ${t}`);
|
||||
rehydratedAt[k] = v;
|
||||
}
|
||||
}
|
||||
this.stats.historical.allTime = rehydratedAt as HistoricalStats;
|
||||
|
||||
// const lr = await this.cache.wrap(`${this.name}-historical-lastReload`, () => createHistoricalDefaults(), {ttl: 0}) as object;
|
||||
// const rehydratedLr: any = {};
|
||||
// for(const [k, v] of Object.entries(lr)) {
|
||||
// if(Array.isArray(v)) {
|
||||
// rehydratedLr[k] = new Map(v);
|
||||
// } else {
|
||||
// rehydratedLr[k] = v;
|
||||
// }
|
||||
// }
|
||||
// this.stats.historical.lastReload = rehydratedLr;
|
||||
}
|
||||
|
||||
updateHistoricalStats(data: HistoricalStatUpdateData) {
|
||||
@@ -298,6 +299,88 @@ export class SubredditResources {
|
||||
return 0;
|
||||
}
|
||||
|
||||
async interactWithCacheByKeyPattern(pattern: string | RegExp, action: 'get' | 'delete') {
|
||||
let patternIsReg = pattern instanceof RegExp;
|
||||
let regPattern: RegExp;
|
||||
let globPattern = pattern;
|
||||
|
||||
const cacheDict: Record<string, any> = {};
|
||||
|
||||
if (typeof pattern === 'string') {
|
||||
const possibleRegPattern = parseStringToRegex(pattern, 'ig');
|
||||
if (possibleRegPattern !== undefined) {
|
||||
regPattern = possibleRegPattern;
|
||||
patternIsReg = true;
|
||||
} else {
|
||||
if (this.prefix !== undefined && !pattern.includes(this.prefix)) {
|
||||
// need to add wildcard to beginning of pattern so that the regex will still match a key with a prefix
|
||||
globPattern = `${this.prefix}${pattern}`;
|
||||
}
|
||||
// @ts-ignore
|
||||
const result = globrex(globPattern, {flags: 'i'});
|
||||
regPattern = result.regex;
|
||||
}
|
||||
} else {
|
||||
regPattern = pattern;
|
||||
}
|
||||
|
||||
if (this.cacheType === 'redis') {
|
||||
// @ts-ignore
|
||||
const redisClient = this.cache.store.getClient();
|
||||
if (patternIsReg) {
|
||||
// scan all and test key by regex
|
||||
for await (const key of redisClient.scanIterator()) {
|
||||
if (regPattern.test(key) && (this.prefix === undefined || key.includes(this.prefix))) {
|
||||
if (action === 'delete') {
|
||||
await redisClient.del(key)
|
||||
} else {
|
||||
cacheDict[key] = await redisClient.get(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// not a regex means we can use glob pattern (more efficient!)
|
||||
for await (const key of redisScanIterator(redisClient, { MATCH: globPattern })) {
|
||||
if (action === 'delete') {
|
||||
await redisClient.del(key)
|
||||
} else {
|
||||
cacheDict[key] = await redisClient.get(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (this.cache.store.keys !== undefined) {
|
||||
for (const key of await this.cache.store.keys()) {
|
||||
if (regPattern.test(key) && (this.prefix === undefined || key.includes(this.prefix))) {
|
||||
if (action === 'delete') {
|
||||
await this.cache.del(key)
|
||||
} else {
|
||||
cacheDict[key] = await this.cache.get(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return cacheDict;
|
||||
}
|
||||
|
||||
async deleteCacheByKeyPattern(pattern: string | RegExp) {
|
||||
return await this.interactWithCacheByKeyPattern(pattern, 'delete');
|
||||
}
|
||||
|
||||
async getCacheByKeyPattern(pattern: string | RegExp) {
|
||||
return await this.interactWithCacheByKeyPattern(pattern, 'get');
|
||||
}
|
||||
|
||||
async resetCacheForItem(item: Comment | Submission | RedditUser) {
|
||||
if (asActivity(item)) {
|
||||
if (this.filterCriteriaTTL !== false) {
|
||||
await this.deleteCacheByKeyPattern(`itemCrit-${item.name}*`);
|
||||
}
|
||||
await this.setActivity(item, false);
|
||||
} else if (isUser(item) && this.filterCriteriaTTL !== false) {
|
||||
await this.deleteCacheByKeyPattern(`authorCrit-*-${getActivityAuthorName(item)}*`);
|
||||
}
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const totals = Object.values(this.stats.cache).reduce((acc, curr) => ({
|
||||
miss: acc.miss + curr.miss,
|
||||
@@ -379,11 +462,8 @@ export class SubredditResources {
|
||||
this.logger.debug(`Cache Hit: Submission ${item.name}`);
|
||||
return cachedSubmission;
|
||||
}
|
||||
// @ts-ignore
|
||||
const submission = await item.fetch();
|
||||
this.stats.cache.submission.miss++;
|
||||
await this.cache.set(hash, submission, {ttl: this.submissionTTL});
|
||||
return submission;
|
||||
return await this.setActivity(item);
|
||||
} else if (this.commentTTL !== false) {
|
||||
hash = `comm-${item.name}`;
|
||||
await this.stats.cache.comment.identifierRequestCount.set(hash, (await this.stats.cache.comment.identifierRequestCount.wrap(hash, () => 0) as number) + 1);
|
||||
@@ -394,11 +474,8 @@ export class SubredditResources {
|
||||
this.logger.debug(`Cache Hit: Comment ${item.name}`);
|
||||
return cachedComment;
|
||||
}
|
||||
// @ts-ignore
|
||||
const comment = await item.fetch();
|
||||
this.stats.cache.comment.miss++;
|
||||
await this.cache.set(hash, comment, {ttl: this.commentTTL});
|
||||
return comment;
|
||||
return this.setActivity(item);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
return await item.fetch();
|
||||
@@ -409,6 +486,37 @@ export class SubredditResources {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
public async setActivity(item: Submission | Comment, tryToFetch = true)
|
||||
{
|
||||
let hash = '';
|
||||
if(this.submissionTTL !== false && isSubmission(item)) {
|
||||
hash = `sub-${item.name}`;
|
||||
if(tryToFetch && item instanceof Submission) {
|
||||
// @ts-ignore
|
||||
const itemToCache = await item.fetch();
|
||||
await this.cache.set(hash, itemToCache, {ttl: this.submissionTTL});
|
||||
return itemToCache;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
await this.cache.set(hash, item, {ttl: this.submissionTTL});
|
||||
return item;
|
||||
}
|
||||
} else if(this.commentTTL !== false){
|
||||
hash = `comm-${item.name}`;
|
||||
if(tryToFetch && item instanceof Comment) {
|
||||
// @ts-ignore
|
||||
const itemToCache = await item.fetch();
|
||||
await this.cache.set(hash, itemToCache, {ttl: this.commentTTL});
|
||||
return itemToCache;
|
||||
} else {
|
||||
// @ts-ignore
|
||||
await this.cache.set(hash, item, {ttl: this.commentTTL});
|
||||
return item;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async hasActivity(item: Submission | Comment) {
|
||||
const hash = asSubmission(item) ? `sub-${item.name}` : `comm-${item.name}`;
|
||||
const res = await this.cache.get(hash);
|
||||
@@ -978,6 +1086,20 @@ export class SubredditResources {
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'isRedditMediaDomain':
|
||||
if((item instanceof Comment)) {
|
||||
log.warn('`isRedditMediaDomain` is not allowed in `itemIs` criteria when the main Activity is a Comment');
|
||||
continue;
|
||||
}
|
||||
// @ts-ignore
|
||||
const isRedditDomain = crit[k] as boolean;
|
||||
// @ts-ignore
|
||||
if (item.is_reddit_media_domain !== isRedditDomain) {
|
||||
// @ts-ignore
|
||||
log.debug(`Failed: Expected => ${k}:${crit[k]} | Found => ${k}:${item.is_reddit_media_domain}`)
|
||||
return false
|
||||
}
|
||||
break;
|
||||
case 'approved':
|
||||
case 'spam':
|
||||
if(!item.can_mod_post) {
|
||||
|
||||
@@ -14,6 +14,7 @@ import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {RichContent} from "../Common/interfaces";
|
||||
import {Cache} from 'cache-manager';
|
||||
import {isScopeError} from "../Utils/Errors";
|
||||
import {ErrorWithCause} from "pony-cause";
|
||||
|
||||
interface RawUserNotesPayload {
|
||||
ver: number,
|
||||
@@ -63,6 +64,7 @@ export class UserNotes {
|
||||
identifier: string;
|
||||
cache: Cache
|
||||
cacheCB: Function;
|
||||
mod?: RedditUser;
|
||||
|
||||
users: Map<string, UserNote[]> = new Map();
|
||||
|
||||
@@ -110,14 +112,22 @@ export class UserNotes {
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
async getMod() {
|
||||
if(this.mod === undefined) {
|
||||
// idgaf
|
||||
// @ts-ignore
|
||||
this.mod = await this.subreddit._r.getMe();
|
||||
}
|
||||
return this.mod as RedditUser;
|
||||
}
|
||||
|
||||
async addUserNote(item: (Submission|Comment), type: string | number, text: string = ''): Promise<UserNote>
|
||||
{
|
||||
const payload = await this.retrieveData();
|
||||
const userName = getActivityAuthorName(item.author);
|
||||
|
||||
// idgaf
|
||||
// @ts-ignore
|
||||
const mod = await this.subreddit._r.getMe();
|
||||
const mod = await this.getMod();
|
||||
if(!payload.constants.users.includes(mod.name)) {
|
||||
this.logger.info(`Mod ${mod.name} does not exist in UserNote constants, adding them`);
|
||||
payload.constants.users.push(mod.name);
|
||||
@@ -134,11 +144,11 @@ export class UserNotes {
|
||||
}
|
||||
payload.blob[userName].ns.push(newNote.toRaw(payload.constants));
|
||||
|
||||
const existingNotes = await this.getUserNotes(item.author);
|
||||
await this.saveData(payload);
|
||||
if(this.notesTTL > 0) {
|
||||
const currNotes = this.users.get(userName) || [];
|
||||
currNotes.push(newNote);
|
||||
this.users.set(userName, currNotes);
|
||||
existingNotes.push(newNote);
|
||||
this.users.set(userName, existingNotes);
|
||||
}
|
||||
return newNote;
|
||||
}
|
||||
@@ -150,7 +160,6 @@ export class UserNotes {
|
||||
}
|
||||
|
||||
async retrieveData(): Promise<RawUserNotesPayload> {
|
||||
let cacheMiss;
|
||||
if (this.notesTTL > 0) {
|
||||
const cachedPayload = await this.cache.get(this.identifier);
|
||||
if (cachedPayload !== undefined && cachedPayload !== null) {
|
||||
@@ -158,19 +167,9 @@ export class UserNotes {
|
||||
return cachedPayload as unknown as RawUserNotesPayload;
|
||||
}
|
||||
this.cacheCB(true);
|
||||
cacheMiss = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// DISABLED for now because I think its causing issues
|
||||
// if(cacheMiss && this.debounceCB !== undefined) {
|
||||
// // timeout is still delayed. its our wiki data and we want it now! cm cacheworth 877 cache now
|
||||
// this.logger.debug(`Detected missed cache on usernotes retrieval while batch (${this.batchCount}) save is in progress, executing save immediately before retrieving new notes...`);
|
||||
// clearTimeout(this.saveDebounce);
|
||||
// await this.debounceCB();
|
||||
// this.debounceCB = undefined;
|
||||
// this.saveDebounce = undefined;
|
||||
// }
|
||||
// @ts-ignore
|
||||
const wiki = this.client.getSubreddit(this.subreddit.display_name).getWikiPage('usernotes');
|
||||
const wikiContent = await wiki.content_md;
|
||||
@@ -199,33 +198,6 @@ export class UserNotes {
|
||||
try {
|
||||
const wiki = this.client.getSubreddit(this.subreddit.display_name).getWikiPage('usernotes');
|
||||
if (this.notesTTL !== false) {
|
||||
// DISABLED for now because if it fails throws an uncaught rejection
|
||||
// and need to figured out how to handle this other than just logging (want to interrupt action flow too?)
|
||||
//
|
||||
// debounce usernote save by 5 seconds -- effectively batch usernote saves
|
||||
//
|
||||
// so that if we are processing a ton of checks that write user notes we aren't calling to save the wiki page on every call
|
||||
// since we also have everything in cache (most likely...)
|
||||
//
|
||||
// TODO might want to increase timeout to 10 seconds
|
||||
// if(this.saveDebounce !== undefined) {
|
||||
// clearTimeout(this.saveDebounce);
|
||||
// }
|
||||
// this.debounceCB = (async function () {
|
||||
// const p = wikiPayload;
|
||||
// // @ts-ignore
|
||||
// const self = this as UserNotes;
|
||||
// // @ts-ignore
|
||||
// self.wiki = await self.subreddit.getWikiPage('usernotes').edit(p);
|
||||
// self.logger.debug(`Batch saved ${self.batchCount} usernotes`);
|
||||
// self.debounceCB = undefined;
|
||||
// self.saveDebounce = undefined;
|
||||
// self.batchCount = 0;
|
||||
// }).bind(this);
|
||||
// this.saveDebounce = setTimeout(this.debounceCB,5000);
|
||||
// this.batchCount++;
|
||||
// this.logger.debug(`Saving Usernotes has been debounced for 5 seconds (${this.batchCount} batched)`)
|
||||
|
||||
// @ts-ignore
|
||||
await wiki.edit(wikiPayload);
|
||||
await this.cache.set(this.identifier, payload, {ttl: this.notesTTL});
|
||||
@@ -237,15 +209,14 @@ export class UserNotes {
|
||||
|
||||
return payload as RawUserNotesPayload;
|
||||
} catch (err: any) {
|
||||
let msg = 'Could not edit usernotes.';
|
||||
let msg = 'Could not edit usernotes!';
|
||||
// Make sure at least one moderator has used toolbox and usernotes before and that this account has editing permissions`;
|
||||
if(isScopeError(err)) {
|
||||
msg = `${msg} The bot account did not have sufficient OAUTH scope to perform this action. You must re-authenticate the bot and ensure it has has 'wikiedit' permissions.`
|
||||
} else {
|
||||
msg = `${msg} Make sure at least one moderator has used toolbox, created a usernote, and that this account has editing permissions for the wiki page.`;
|
||||
}
|
||||
this.logger.error(msg, err);
|
||||
throw new LoggedError(msg);
|
||||
throw new ErrorWithCause(msg, {cause: err});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,7 +503,7 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt
|
||||
break;
|
||||
case 'isMod':
|
||||
const mods: RedditUser[] = await item.subreddit.getModerators();
|
||||
const isModerator = mods.some(x => x.name === authorName);
|
||||
const isModerator = mods.some(x => x.name === authorName) || authorName.toLowerCase() === 'automoderator';
|
||||
const modMatch = authorOpts.isMod === isModerator;
|
||||
propResultsMap.isMod!.found = isModerator;
|
||||
propResultsMap.isMod!.passed = !((include && !modMatch) || (!include && modMatch));
|
||||
|
||||
@@ -744,6 +744,7 @@ const webClient = async (options: OperatorConfig) => {
|
||||
});
|
||||
|
||||
if(accessibleInstance === undefined) {
|
||||
logger.warn(`User ${user.name} is not an operator and has no subreddits in common with any *running* bot instances. If you are sure they should have common subreddits then this client may not be able to access all defined CM servers or the bot may be offline.`);
|
||||
return res.render('noAccess');
|
||||
}
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ const status = () => {
|
||||
eventsState: m.eventsState,
|
||||
queueState: m.queueState,
|
||||
indicator: 'gray',
|
||||
permissions: await m.getModPermissions(),
|
||||
queuedActivities: m.queue.length(),
|
||||
runningActivities: m.queue.running(),
|
||||
maxWorkers: m.queue.concurrency,
|
||||
@@ -232,12 +233,14 @@ const status = () => {
|
||||
const cacheMiss = subManagerData.reduce((acc, curr) => acc + curr.stats.cache.totalMiss, 0);
|
||||
const sharedSub = subManagerData.find(x => x.stats.cache.isShared);
|
||||
const sharedCount = sharedSub !== undefined ? sharedSub.stats.cache.currentKeyCount : 0;
|
||||
const scopes = req.user?.isInstanceOperator(bot) ? bot.client.scope : [];
|
||||
let allManagerData: any = {
|
||||
name: 'All',
|
||||
status: bot.running ? 'RUNNING' : 'NOT RUNNING',
|
||||
indicator: bot.running ? 'green' : 'grey',
|
||||
maxWorkers,
|
||||
globalMaxWorkers,
|
||||
scopes: scopes === null || !Array.isArray(scopes) ? [] : scopes,
|
||||
subMaxWorkers,
|
||||
runningActivities,
|
||||
queuedActivities,
|
||||
|
||||
@@ -152,3 +152,7 @@ a {
|
||||
#saveTip .tooltip:hover {
|
||||
transition-delay: 1s;
|
||||
}
|
||||
|
||||
#redditStatus .iconify-inline {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
@@ -3,3 +3,28 @@
|
||||
<a href="https://github.com/FoxxMD/context-mod">ContextMod Web</a> created by /u/FoxxMD
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript" src="https://cdn.statuspage.io/se-v2.js"></script>
|
||||
<script>
|
||||
// https://www.redditstatus.com/api#status
|
||||
var sp = new StatusPage.page({ page : '2kbc0d48tv3j' });
|
||||
sp.status({
|
||||
success : function(data) {
|
||||
debugger;
|
||||
console.log(data.status.indicator);
|
||||
switch(data.status.indicator){
|
||||
case 'minor':
|
||||
document.querySelector('#redditStatus').innerHTML = '<span class="iconify-inline yellow" data-icon="ep:warning-filled"></span>';
|
||||
break;
|
||||
case 'none':
|
||||
document.querySelector('#redditStatus').innerHTML = '<span class="iconify-inline green" data-icon="ep:circle-check-filled"></span>';
|
||||
break;
|
||||
default:
|
||||
document.querySelector('#redditStatus').innerHTML = '<span class="iconify-inline red" data-icon="ep:warning-filled"></span>';
|
||||
break;
|
||||
}
|
||||
// data.page.updated_at
|
||||
// data.status.indicator => none, minor, major, or critical
|
||||
// data.status.description
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<head>
|
||||
<link rel="stylesheet" href="/public/tailwind.min.css"/>
|
||||
<script src="https://code.iconify.design/2/2.1.0/iconify.min.js"></script>
|
||||
<script src="https://code.iconify.design/2/2.1.2/iconify.min.js"></script>
|
||||
<link rel="stylesheet" href="/public/themeToggle.css">
|
||||
<link rel="stylesheet" href="/public/app.css">
|
||||
<title><%= locals.title !== undefined ? title : `${locals.botName !== undefined ? `CM for ${botName}` : 'ContextMod'}`%></title>
|
||||
|
||||
@@ -33,7 +33,12 @@
|
||||
</ul>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="flex items-center flex-end text-sm">
|
||||
<div class="flex items-center mr-8 text-sm">
|
||||
<a href="https://redditstatus.com" target="_blank">
|
||||
<span>Reddit Status: <span id="redditStatus" class="ml-2"><span class="iconify-inline" data-icon="ep:question-filled"></span></span></span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex items-center text-sm">
|
||||
<a href="logout">Logout</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -160,6 +160,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label>Mod Perms</label>
|
||||
<span class="has-tooltip">
|
||||
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
|
||||
<span>
|
||||
<ul class="list-inside list-disc">
|
||||
<% data.permissions.forEach(function (i){ %>
|
||||
<li class="font-mono"><%= i %></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</span>
|
||||
</span>
|
||||
<span class="cursor-help underline" style="text-decoration-style: dotted"><%= data.permissions.length %></span>
|
||||
</span>
|
||||
<label>Slow Mode</label>
|
||||
<span><%= data.delayBy %></span>
|
||||
<% } %>
|
||||
@@ -223,6 +236,19 @@
|
||||
<% if (data.name === 'All' && isOperator) { %>
|
||||
<label>Operators</label>
|
||||
<span><%= operators %></span>
|
||||
<label>Oauth Scopes</label>
|
||||
<span class="has-tooltip">
|
||||
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
|
||||
<span>
|
||||
<ul class="list-inside list-disc">
|
||||
<% data.scopes.forEach(function (i){ %>
|
||||
<li class="font-mono"><%= i %></li>
|
||||
<% }) %>
|
||||
</ul>
|
||||
</span>
|
||||
</span>
|
||||
<span class="cursor-help underline" style="text-decoration-style: dotted"><%= data.scopes.length %></span>
|
||||
</span>
|
||||
<% } else %>
|
||||
</div>
|
||||
<% if (data.name !== 'All') { %>
|
||||
|
||||
63
src/util.ts
@@ -6,8 +6,6 @@ import deepEqual from "fast-deep-equal";
|
||||
import {Duration} from 'dayjs/plugin/duration.js';
|
||||
import Ajv from "ajv";
|
||||
import {InvalidOptionArgumentError} from "commander";
|
||||
import Submission from "snoowrap/dist/objects/Submission";
|
||||
import {Comment} from "snoowrap";
|
||||
import {inflateSync, deflateSync} from "zlib";
|
||||
import pixelmatch from 'pixelmatch';
|
||||
import os from 'os';
|
||||
@@ -44,7 +42,7 @@ import crypto from "crypto";
|
||||
import Autolinker from 'autolinker';
|
||||
import {create as createMemoryStore} from './Utils/memoryStore';
|
||||
import {MESSAGE, LEVEL} from "triple-beam";
|
||||
import {RedditUser} from "snoowrap/dist/objects";
|
||||
import {RedditUser,Comment,Submission} from "snoowrap/dist/objects";
|
||||
import reRegExp from '@stdlib/regexp-regexp';
|
||||
import fetch, {Response} from "node-fetch";
|
||||
import { URL } from "url";
|
||||
@@ -160,7 +158,7 @@ const isProbablyError = (val: any, errName = 'error') => {
|
||||
return typeof val === 'object' && val.name !== undefined && val.name.toLowerCase().includes(errName);
|
||||
}
|
||||
|
||||
export const PASS = '✔';
|
||||
export const PASS = '✓';
|
||||
export const FAIL = '✘';
|
||||
|
||||
export const truncateStringToLength = (length: number, truncStr = '...') => (str: string) => str.length > length ? `${str.slice(0, length - truncStr.length - 1)}${truncStr}` : str;
|
||||
@@ -1577,13 +1575,33 @@ export const snooLogWrapper = (logger: Logger) => {
|
||||
* Cached activities lose type information when deserialized so need to check properties as well to see if the object is the shape of a Submission
|
||||
* */
|
||||
export const isSubmission = (value: any) => {
|
||||
return value instanceof Submission || value.domain !== undefined;
|
||||
return value instanceof Submission || value.name.includes('t3_');
|
||||
}
|
||||
|
||||
export const asSubmission = (value: any): value is Submission => {
|
||||
return isSubmission(value);
|
||||
}
|
||||
|
||||
export const isComment = (value: any) => {
|
||||
return value instanceof Comment || value.name.includes('t1_');
|
||||
}
|
||||
|
||||
export const asComment = (value: any): value is Comment => {
|
||||
return isComment(value);
|
||||
}
|
||||
|
||||
export const asActivity = (value: any): value is (Submission | Comment) => {
|
||||
return asComment(value) || asSubmission(value);
|
||||
}
|
||||
|
||||
export const isUser = (value: any) => {
|
||||
return value instanceof RedditUser || value.name.includes('t2_');
|
||||
}
|
||||
|
||||
export const asUser = (value: any): value is RedditUser => {
|
||||
return isUser(value);
|
||||
}
|
||||
|
||||
export const isUserNoteCriteria = (value: any) => {
|
||||
return value !== null && typeof value === 'object' && value.type !== undefined;
|
||||
}
|
||||
@@ -2020,3 +2038,38 @@ export const likelyJson5 = (str: string): boolean => {
|
||||
}
|
||||
return validStart;
|
||||
}
|
||||
|
||||
const defaultScanOptions = {
|
||||
COUNT: '100',
|
||||
MATCH: '*'
|
||||
}
|
||||
/**
|
||||
* Frankenstein redis scan generator
|
||||
*
|
||||
* Cannot use the built-in scan iterator because it is only available in > v4 of redis client but node-cache-manager-redis is using v3.x --
|
||||
* So combining the async iterator defined in v4 from here https://github.com/redis/node-redis/blob/master/packages/client/lib/client/index.ts#L587
|
||||
* with the scan example from v3 https://github.com/redis/node-redis/blob/8a43dea9bee11e41d33502850f6989943163020a/examples/scan.js
|
||||
*
|
||||
* */
|
||||
export async function* redisScanIterator(client: any, options: any = {}): AsyncIterable<string> {
|
||||
let cursor: string = '0';
|
||||
const scanOpts = {...defaultScanOptions, ...options};
|
||||
do {
|
||||
const iterScan = new Promise((resolve, reject) => {
|
||||
client.scan(cursor, 'MATCH', scanOpts.MATCH, 'COUNT', scanOpts.COUNT, (err: any, res: any) => {
|
||||
if(err) {
|
||||
return reject(err);
|
||||
} else {
|
||||
const newCursor = res[0];
|
||||
let keys = res[1];
|
||||
resolve([newCursor, keys]);
|
||||
}
|
||||
});
|
||||
}) as Promise<[any, string[]]>;
|
||||
const [newCursor, keys] = await iterScan;
|
||||
cursor = newCursor;
|
||||
for (const key of keys) {
|
||||
yield key;
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
}
|
||||
|
||||