mirror of
https://github.com/FoxxMD/context-mod.git
synced 2026-01-13 23:48:19 -05:00
* Allow checking if user is shadowbanned via authorIs (AuthorCriteria) * try-catch on history get or author criteria to try to detect shadowbanned user for a more descriptive error
1161 lines
42 KiB
TypeScript
1161 lines
42 KiB
TypeScript
import winston, {Logger} from "winston";
|
|
import jsonStringify from 'safe-stable-stringify';
|
|
import dayjs, {Dayjs, OpUnitType} from 'dayjs';
|
|
import {FormattedRuleResult, isRuleSetResult, RulePremise, RuleResult, RuleSetResult} from "./Rule";
|
|
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 {
|
|
ActivityWindowCriteria, CacheOptions, CacheProvider,
|
|
DurationComparison,
|
|
GenericComparison, LogInfo, NamedGroup,
|
|
PollingOptionsStrong, RedditEntity, RedditEntityType, RegExResult, ResourceStats, StatusCodeError,
|
|
StringOperator, StrongSubredditState, SubredditState
|
|
} from "./Common/interfaces";
|
|
import JSON5 from "json5";
|
|
import yaml, {JSON_SCHEMA} from "js-yaml";
|
|
import SimpleError from "./Utils/SimpleError";
|
|
import InvalidRegexError from "./Utils/InvalidRegexError";
|
|
import {constants, promises} from "fs";
|
|
import {cacheOptDefaults} from "./Common/defaults";
|
|
import cacheManager, {Cache} from "cache-manager";
|
|
import redisStore from "cache-manager-redis-store";
|
|
import crypto from "crypto";
|
|
import Autolinker from 'autolinker';
|
|
import {create as createMemoryStore} from './Utils/memoryStore';
|
|
import {MESSAGE} from "triple-beam";
|
|
import {RedditUser} from "snoowrap/dist/objects";
|
|
import reRegExp from '@stdlib/regexp-regexp';
|
|
|
|
const ReReg = reRegExp();
|
|
|
|
const {format} = winston;
|
|
const {combine, printf, timestamp, label, splat, errors} = format;
|
|
|
|
const s = splat();
|
|
const SPLAT = Symbol.for('splat')
|
|
//const errorsFormat = errors({stack: true});
|
|
const CWD = process.cwd();
|
|
|
|
// const errorAwareFormat = (info: any) => {
|
|
// if(info instanceof SimpleError) {
|
|
// return errors()(info);
|
|
// }
|
|
// }
|
|
const errorAwareFormat = {
|
|
transform: (info: any, opts: any) => {
|
|
// don't need to log stack trace if we know the error is just a simple message (we handled it)
|
|
const stack = !(info instanceof SimpleError) && !(info.message instanceof SimpleError);
|
|
const {name, response, message, stack: errStack, error, statusCode} = info;
|
|
if(name === 'StatusCodeError' && response !== undefined && response.headers !== undefined && response.headers['content-type'].includes('html')) {
|
|
// reddit returns html even when we specify raw_json in the querystring (via snoowrap)
|
|
// which means the html gets set as the message for the error AND gets added to the stack as the message
|
|
// and we end up with a h u g e log statement full of noisy html >:(
|
|
|
|
const errorSample = error.slice(0, 10);
|
|
const messageBeforeIndex = message.indexOf(errorSample);
|
|
let newMessage = `Status Error ${statusCode} from Reddit`;
|
|
if(messageBeforeIndex > 0) {
|
|
newMessage = `${message.slice(0, messageBeforeIndex)} - ${newMessage}`;
|
|
}
|
|
let cleanStack = errStack;
|
|
|
|
// try to get just stacktrace by finding beginning of what we assume is the actual trace
|
|
if(errStack) {
|
|
cleanStack = `${newMessage}\n${errStack.slice(errStack.indexOf('at new StatusCodeError'))}`;
|
|
}
|
|
// now put it all together so its nice and clean
|
|
info.message = newMessage;
|
|
info.stack = cleanStack;
|
|
}
|
|
return errors().transform(info, { stack });
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
export const defaultFormat = (defaultLabel = 'App') => printf(({
|
|
level,
|
|
message,
|
|
labels = [defaultLabel],
|
|
subreddit,
|
|
bot,
|
|
instance,
|
|
leaf,
|
|
itemId,
|
|
timestamp,
|
|
// @ts-ignore
|
|
[SPLAT]: splatObj,
|
|
stack,
|
|
...rest
|
|
}) => {
|
|
let stringifyValue = splatObj !== undefined ? jsonStringify(splatObj) : '';
|
|
let msg = message;
|
|
let stackMsg = '';
|
|
if (stack !== undefined) {
|
|
const stackArr = stack.split('\n');
|
|
const stackTop = stackArr[0];
|
|
const cleanedStack = stackArr
|
|
.slice(1) // don't need actual error message since we are showing it as msg
|
|
.map((x: string) => x.replace(CWD, 'CWD')) // replace file location up to cwd for user privacy
|
|
.join('\n'); // rejoin with newline to preserve formatting
|
|
stackMsg = `\n${cleanedStack}`;
|
|
if (msg === undefined || msg === null || typeof message === 'object') {
|
|
msg = stackTop;
|
|
} else {
|
|
stackMsg = `\n${stackTop}${stackMsg}`
|
|
}
|
|
}
|
|
|
|
let nodes = labels;
|
|
if (leaf !== null && leaf !== undefined) {
|
|
nodes.push(leaf);
|
|
}
|
|
const labelContent = `${nodes.map((x: string) => `[${x}]`).join(' ')}`;
|
|
|
|
return `${timestamp} ${level.padEnd(7)}: ${instance !== undefined ? `|${instance}| ` : ''}${bot !== undefined ? `~${bot}~ ` : ''}${subreddit !== undefined ? `{${subreddit}} ` : ''}${labelContent} ${msg}${stringifyValue !== '' ? ` ${stringifyValue}` : ''}${stackMsg}`;
|
|
});
|
|
|
|
|
|
export const labelledFormat = (labelName = 'App') => {
|
|
//const l = label({label: labelName, message: false});
|
|
return combine(
|
|
timestamp(
|
|
{
|
|
format: () => dayjs().local().format(),
|
|
}
|
|
),
|
|
// l,
|
|
s,
|
|
errorAwareFormat,
|
|
//errorsFormat,
|
|
defaultFormat(labelName),
|
|
);
|
|
}
|
|
|
|
export interface groupByOptions {
|
|
lowercase?: boolean
|
|
}
|
|
|
|
/**
|
|
* Group array of objects by given keys
|
|
* @param keys keys to be grouped by
|
|
* @param opts
|
|
* @param array objects to be grouped
|
|
* @returns an object with objects in `array` grouped by `keys`
|
|
* @see <https://gist.github.com/mikaello/06a76bca33e5d79cdd80c162d7774e9c>
|
|
*/
|
|
export const groupBy = <T>(keys: (keyof T)[], opts: groupByOptions = {}) => (array: T[]): Record<string, T[]> => {
|
|
const {lowercase = false} = opts;
|
|
return array.reduce((objectsByKeyValue, obj) => {
|
|
let value = keys.map((key) => obj[key]).join('-');
|
|
if (lowercase) {
|
|
value = value.toLowerCase();
|
|
}
|
|
objectsByKeyValue[value] = (objectsByKeyValue[value] || []).concat(obj);
|
|
return objectsByKeyValue;
|
|
}, {} as Record<string, T[]>)
|
|
};
|
|
|
|
// match /mealtimesvideos/ /comments/ etc... (?:\/.*\/)
|
|
// matches https://old.reddit.com/r (?:^.+?)(?:reddit.com\/r)
|
|
// (?:^.+?)(?:reddit.com\/r\/.+\/.\/)
|
|
// (?:.*\/)([\d\w]+?)(?:\/*)
|
|
|
|
/**
|
|
* @see https://stackoverflow.com/a/61033353/1469797
|
|
*/
|
|
const REGEX_YOUTUBE: RegExp = /(?:https?:\/\/)?(?:www\.)?youtu(?:\.be\/|be.com\/\S*(?:watch|embed)(?:(?:(?=\/[^&\s\?]+(?!\S))\/)|(?:\S*v=|v\/)))([^&\s\?]+)/g;
|
|
|
|
export const parseUsableLinkIdentifier = (regexes: RegExp[] = [REGEX_YOUTUBE]) => (val?: string): (string | undefined) => {
|
|
if (val === undefined) {
|
|
return val;
|
|
}
|
|
for (const reg of regexes) {
|
|
const matches = [...val.matchAll(reg)];
|
|
if (matches.length > 0) {
|
|
// use first capture group
|
|
// TODO make this configurable at some point?
|
|
const captureGroup = matches[0][matches[0].length - 1];
|
|
if(captureGroup !== '') {
|
|
return captureGroup;
|
|
}
|
|
}
|
|
}
|
|
return val;
|
|
}
|
|
|
|
export const parseLinkIdentifier = (regexes: RegExp[]) => {
|
|
const u = parseUsableLinkIdentifier(regexes);
|
|
return (val: string): (string | undefined) => {
|
|
const id = u(val);
|
|
if (id === val) {
|
|
return undefined;
|
|
}
|
|
return id;
|
|
}
|
|
}
|
|
|
|
export const SUBMISSION_URL_ID: RegExp = /(?:^.+?)(?:reddit.com\/r)(?:\/[\w\d]+){2}(?:\/)([\w\d]*)/g;
|
|
export const COMMENT_URL_ID: RegExp = /(?:^.+?)(?:reddit.com\/r)(?:\/[\w\d]+){4}(?:\/)([\w\d]*)/g;
|
|
|
|
export function sleep(ms: number) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
export const findResultByPremise = (premise: RulePremise, results: RuleResult[]): (RuleResult | undefined) => {
|
|
if (results.length === 0) {
|
|
return undefined;
|
|
}
|
|
return results.find((x) => {
|
|
return deepEqual(premise, x.premise);
|
|
})
|
|
}
|
|
|
|
export const determineNewResults = (existing: RuleResult[], val: RuleResult | RuleResult[]): RuleResult[] => {
|
|
const requestedResults = Array.isArray(val) ? val : [val];
|
|
const combined = [...existing];
|
|
const newResults = [];
|
|
|
|
// not sure this should be used since grouped results will be stale as soon as a new result is added --
|
|
// would need a guarantee all results in val are unique
|
|
// const groupedResultsByKind = newResults.reduce((grouped, res) => {
|
|
// grouped[res.premise.kind] = (grouped[res.premise.kind] || []).concat(res);
|
|
// return grouped;
|
|
// }, {} as Record<string, RuleResult[]>);
|
|
// for(const kind of Object.keys(groupedResultsByKind)) {
|
|
// const relevantExisting = combined.filter(x => x.premise.kind === kind)
|
|
// }
|
|
|
|
for (const result of requestedResults) {
|
|
const relevantExisting = combined.filter(x => x.premise.kind === result.premise.kind).find(x => deepEqual(x.premise, result.premise));
|
|
if (relevantExisting === undefined) {
|
|
combined.push(result);
|
|
newResults.push(result);
|
|
}
|
|
}
|
|
return newResults;
|
|
}
|
|
|
|
export const mergeArr = (objValue: [], srcValue: []): (any[] | undefined) => {
|
|
if (Array.isArray(objValue)) {
|
|
return objValue.concat(srcValue);
|
|
}
|
|
}
|
|
|
|
export const ruleNamesFromResults = (results: RuleResult[]) => {
|
|
return results.map(x => x.name || x.premise.kind).join(' | ')
|
|
}
|
|
|
|
export const triggeredIndicator = (val: boolean | null): string => {
|
|
if(val === null) {
|
|
return '-';
|
|
}
|
|
return val ? PASS : FAIL;
|
|
}
|
|
|
|
export const resultsSummary = (results: (RuleResult|RuleSetResult)[], topLevelCondition: 'OR' | 'AND'): string => {
|
|
const parts: string[] = results.map((x) => {
|
|
if(isRuleSetResult(x)) {
|
|
return `${triggeredIndicator(x.triggered)} (${resultsSummary(x.results, x.condition)}${x.results.length === 1 ? ` [${x.condition}]` : ''})`;
|
|
}
|
|
const res = x as RuleResult;
|
|
return `${triggeredIndicator(x.triggered)} ${res.name}`;
|
|
});
|
|
return parts.join(` ${topLevelCondition} `)
|
|
//return results.map(x => x.name || x.premise.kind).join(' | ')
|
|
}
|
|
|
|
export const createAjvFactory = (logger: Logger) => {
|
|
return new Ajv({logger: logger, verbose: true, strict: "log", allowUnionTypes: true});
|
|
}
|
|
|
|
export const percentFromString = (str: string): number => {
|
|
const n = Number.parseInt(str.replace('%', ''));
|
|
if(Number.isNaN(n)) {
|
|
throw new Error(`${str} could not be parsed to a number`);
|
|
}
|
|
return n / 100;
|
|
}
|
|
|
|
export interface numberFormatOptions {
|
|
toFixed: number,
|
|
defaultVal?: any,
|
|
prefix?: string,
|
|
suffix?: string,
|
|
round?: {
|
|
type?: string,
|
|
enable: boolean,
|
|
indicate?: boolean,
|
|
}
|
|
}
|
|
|
|
export const formatNumber = (val: number | string, options?: numberFormatOptions) => {
|
|
const {
|
|
toFixed = 2,
|
|
defaultVal = null,
|
|
prefix = '',
|
|
suffix = '',
|
|
round,
|
|
} = options || {};
|
|
let parsedVal = typeof val === 'number' ? val : Number.parseFloat(val);
|
|
if (Number.isNaN(parsedVal)) {
|
|
return defaultVal;
|
|
}
|
|
let prefixStr = prefix;
|
|
const {enable = false, indicate = true, type = 'round'} = round || {};
|
|
if (enable && !Number.isInteger(parsedVal)) {
|
|
switch (type) {
|
|
case 'round':
|
|
parsedVal = Math.round(parsedVal);
|
|
break;
|
|
case 'ceil':
|
|
parsedVal = Math.ceil(parsedVal);
|
|
break;
|
|
case 'floor':
|
|
parsedVal = Math.floor(parsedVal);
|
|
}
|
|
if (indicate) {
|
|
prefixStr = `~${prefix}`;
|
|
}
|
|
}
|
|
const localeString = parsedVal.toLocaleString(undefined, {
|
|
minimumFractionDigits: toFixed,
|
|
maximumFractionDigits: toFixed,
|
|
});
|
|
return `${prefixStr}${localeString}${suffix}`;
|
|
};
|
|
|
|
export function argParseInt(value: any, prev: any = undefined): number {
|
|
let usedVal = value;
|
|
if (value === undefined || value === '') {
|
|
usedVal = prev;
|
|
}
|
|
if(usedVal === undefined || usedVal === '') {
|
|
return usedVal;
|
|
}
|
|
|
|
if (typeof usedVal === 'string') {
|
|
const parsedValue = parseInt(usedVal, 10);
|
|
if (isNaN(parsedValue)) {
|
|
throw new InvalidOptionArgumentError('Not a number.');
|
|
}
|
|
return parsedValue;
|
|
} else if (typeof usedVal === 'number') {
|
|
return usedVal;
|
|
}
|
|
throw new InvalidOptionArgumentError('Not a number.');
|
|
}
|
|
|
|
export function parseBool(value: any, prev: any = false): boolean {
|
|
let usedVal = value;
|
|
if (value === undefined || value === '') {
|
|
usedVal = prev;
|
|
}
|
|
if(usedVal === undefined || usedVal === '') {
|
|
return false;
|
|
}
|
|
if (typeof usedVal === 'string') {
|
|
return usedVal === 'true';
|
|
} else if (typeof usedVal === 'boolean') {
|
|
return usedVal;
|
|
}
|
|
throw new InvalidOptionArgumentError('Not a boolean value.');
|
|
}
|
|
|
|
export const parseBoolWithDefault = (defaultValue: any) => (arg: any, prevVal: any) => {
|
|
parseBool(arg, defaultValue)
|
|
};
|
|
|
|
export function activityWindowText(activities: (Submission | Comment)[], suffix = false): (string | undefined) {
|
|
if (activities.length === 0) {
|
|
return undefined;
|
|
}
|
|
if (activities.length === 1) {
|
|
return `1 Item`;
|
|
}
|
|
|
|
return dayjs.duration(dayjs(activities[0].created_utc * 1000).diff(dayjs(activities[activities.length - 1].created_utc * 1000))).humanize(suffix);
|
|
}
|
|
|
|
export function normalizeName(val: string) {
|
|
return val.trim().replace(/\W+/g, '').toLowerCase()
|
|
}
|
|
|
|
// https://github.com/toolbox-team/reddit-moderator-toolbox/wiki/Subreddit-Wikis%3A-usernotes#working-with-the-blob
|
|
export const inflateUserNotes = (blob: string) => {
|
|
|
|
const buffer = Buffer.from(blob, 'base64');
|
|
const str = inflateSync(buffer).toString('utf-8');
|
|
|
|
// @ts-ignore
|
|
return JSON.parse(str);
|
|
}
|
|
// https://github.com/toolbox-team/reddit-moderator-toolbox/wiki/Subreddit-Wikis%3A-usernotes#working-with-the-blob
|
|
export const deflateUserNotes = (usersObject: object) => {
|
|
const jsonString = JSON.stringify(usersObject);
|
|
|
|
// Deflate/compress the string
|
|
const binaryData = deflateSync(jsonString);
|
|
|
|
// Convert binary data to a base64 string with a Buffer
|
|
const blob = Buffer.from(binaryData).toString('base64');
|
|
return blob;
|
|
}
|
|
|
|
export const isActivityWindowCriteria = (val: any): val is ActivityWindowCriteria => {
|
|
if (val !== null && typeof val === 'object') {
|
|
return (val.count !== undefined && typeof val.count === 'number') ||
|
|
// close enough
|
|
val.duration !== undefined;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const parseFromJsonOrYamlToObject = (content: string): [object?, Error?, Error?] => {
|
|
let obj;
|
|
let jsonErr,
|
|
yamlErr;
|
|
|
|
try {
|
|
obj = JSON5.parse(content);
|
|
const oType = obj === null ? 'null' : typeof obj;
|
|
if (oType !== 'object') {
|
|
jsonErr = new SimpleError(`Parsing as json produced data of type '${oType}' (expected 'object')`);
|
|
obj = undefined;
|
|
}
|
|
} catch (err) {
|
|
jsonErr = err;
|
|
}
|
|
if (obj === undefined) {
|
|
try {
|
|
obj = yaml.load(content, {schema: JSON_SCHEMA, json: true});
|
|
const oType = obj === null ? 'null' : typeof obj;
|
|
if (oType !== 'object') {
|
|
yamlErr = new SimpleError(`Parsing as yaml produced data of type '${oType}' (expected 'object')`);
|
|
obj = undefined;
|
|
}
|
|
} catch (err) {
|
|
yamlErr = err;
|
|
}
|
|
}
|
|
return [obj, jsonErr, yamlErr];
|
|
}
|
|
|
|
export const comparisonTextOp = (val1: number, strOp: string, val2: number): boolean => {
|
|
switch (strOp) {
|
|
case '>':
|
|
return val1 > val2;
|
|
case '>=':
|
|
return val1 >= val2;
|
|
case '<':
|
|
return val1 < val2;
|
|
case '<=':
|
|
return val1 <= val2;
|
|
default:
|
|
throw new Error(`${strOp} was not a recognized operator`);
|
|
}
|
|
}
|
|
|
|
const GENERIC_VALUE_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)(?<extra>\s+.*)*$/
|
|
const GENERIC_VALUE_COMPARISON_URL = 'https://regexr.com/60dq4';
|
|
export const parseGenericValueComparison = (val: string): GenericComparison => {
|
|
const matches = val.match(GENERIC_VALUE_COMPARISON);
|
|
if (matches === null) {
|
|
throw new InvalidRegexError(GENERIC_VALUE_COMPARISON, val, GENERIC_VALUE_COMPARISON_URL)
|
|
}
|
|
const groups = matches.groups as any;
|
|
|
|
return {
|
|
operator: groups.opStr as StringOperator,
|
|
value: Number.parseFloat(groups.value),
|
|
isPercent: false,
|
|
extra: groups.extra,
|
|
displayText: `${groups.opStr} ${groups.value}`
|
|
}
|
|
}
|
|
|
|
const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?<opStr>>|>=|<|<=)\s*(?<value>\d+)\s*(?<percent>%?)(?<extra>.*)$/
|
|
const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/60a16';
|
|
export const parseGenericValueOrPercentComparison = (val: string): GenericComparison => {
|
|
const matches = val.match(GENERIC_VALUE_PERCENT_COMPARISON);
|
|
if (matches === null) {
|
|
throw new InvalidRegexError(GENERIC_VALUE_PERCENT_COMPARISON, val, GENERIC_VALUE_PERCENT_COMPARISON_URL)
|
|
}
|
|
const groups = matches.groups as any;
|
|
|
|
return {
|
|
operator: groups.opStr as StringOperator,
|
|
value: Number.parseFloat(groups.value),
|
|
isPercent: groups.percent !== '',
|
|
extra: groups.extra,
|
|
displayText: `${groups.opStr} ${groups.value}${groups.percent === undefined ? '': '%'}`
|
|
}
|
|
}
|
|
|
|
export const dateComparisonTextOp = (val1: Dayjs, strOp: StringOperator, val2: Dayjs, granularity?: OpUnitType): boolean => {
|
|
switch (strOp) {
|
|
case '>':
|
|
return val1.isBefore(val2, granularity);
|
|
case '>=':
|
|
return val1.isSameOrBefore(val2, granularity);
|
|
case '<':
|
|
return val1.isAfter(val2, granularity);
|
|
case '<=':
|
|
return val1.isSameOrAfter(val2, granularity);
|
|
default:
|
|
throw new Error(`${strOp} was not a recognized operator`);
|
|
}
|
|
}
|
|
|
|
const ISO8601_REGEX: RegExp = /^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/;
|
|
const DURATION_REGEX: RegExp = /^\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$/;
|
|
export const parseDuration = (val: string): Duration => {
|
|
let matches = val.match(DURATION_REGEX);
|
|
if (matches !== null) {
|
|
const groups = matches.groups as any;
|
|
const dur: Duration = dayjs.duration(groups.time, groups.unit);
|
|
if (!dayjs.isDuration(dur)) {
|
|
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
|
|
}
|
|
return dur;
|
|
}
|
|
matches = val.match(ISO8601_REGEX);
|
|
if (matches !== null) {
|
|
const dur: Duration = dayjs.duration(val);
|
|
if (!dayjs.isDuration(dur)) {
|
|
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
|
|
}
|
|
return dur;
|
|
}
|
|
throw new InvalidRegexError([DURATION_REGEX, ISO8601_REGEX], val)
|
|
}
|
|
|
|
/**
|
|
* Named groups: operator, time, unit
|
|
* */
|
|
const DURATION_COMPARISON_REGEX: RegExp = /^\s*(?<opStr>>|>=|<|<=)\s*(?<time>\d+)\s*(?<unit>days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?)\s*$/;
|
|
const DURATION_COMPARISON_REGEX_URL = 'https://regexr.com/609n8';
|
|
export const parseDurationComparison = (val: string): DurationComparison => {
|
|
const matches = val.match(DURATION_COMPARISON_REGEX);
|
|
if (matches === null) {
|
|
throw new InvalidRegexError(DURATION_COMPARISON_REGEX, val, DURATION_COMPARISON_REGEX_URL)
|
|
}
|
|
const groups = matches.groups as any;
|
|
const dur: Duration = dayjs.duration(groups.time, groups.unit);
|
|
if (!dayjs.isDuration(dur)) {
|
|
throw new SimpleError(`Parsed value '${val}' did not result in a valid Dayjs Duration`);
|
|
}
|
|
return {
|
|
operator: groups.opStr as StringOperator,
|
|
duration: dur
|
|
}
|
|
}
|
|
export const compareDurationValue = (comp: DurationComparison, date: Dayjs) => {
|
|
const dateToCompare = dayjs().subtract(comp.duration.asSeconds(), 'seconds');
|
|
return dateComparisonTextOp(date, comp.operator, dateToCompare);
|
|
}
|
|
|
|
const SUBREDDIT_NAME_REGEX: RegExp = /^\s*(?:\/r\/|r\/)*(\w+)*\s*$/;
|
|
const SUBREDDIT_NAME_REGEX_URL = 'https://regexr.com/61a1d';
|
|
export const parseSubredditName = (val:string): string => {
|
|
const matches = val.match(SUBREDDIT_NAME_REGEX);
|
|
if (matches === null) {
|
|
throw new InvalidRegexError(SUBREDDIT_NAME_REGEX, val, SUBREDDIT_NAME_REGEX_URL)
|
|
}
|
|
return matches[1] as string;
|
|
}
|
|
|
|
export const REDDIT_ENTITY_REGEX: RegExp = /^\s*(?<entityType>\/[ru]\/|[ru]\/)*(?<name>\w+)*\s*$/;
|
|
export const REDDIT_ENTITY_REGEX_URL = 'https://regexr.com/65r9b';
|
|
export const parseRedditEntity = (val:string): RedditEntity => {
|
|
const matches = val.match(REDDIT_ENTITY_REGEX);
|
|
if (matches === null) {
|
|
throw new InvalidRegexError(REDDIT_ENTITY_REGEX, val, REDDIT_ENTITY_REGEX_URL)
|
|
}
|
|
const groups = matches.groups as any;
|
|
let eType: RedditEntityType = 'user';
|
|
if(groups.entityType !== undefined && typeof groups.entityType === 'string' && groups.entityType.includes('r')) {
|
|
eType = 'subreddit';
|
|
}
|
|
return {
|
|
name: groups.name,
|
|
type: eType,
|
|
}
|
|
}
|
|
|
|
const WIKI_REGEX: RegExp = /^\s*wiki:(?<url>[^|]+)\|*(?<subreddit>[^\s]*)\s*$/;
|
|
const WIKI_REGEX_URL = 'https://regexr.com/61bq1';
|
|
const URL_REGEX: RegExp = /^\s*url:(?<url>[^\s]+)\s*$/;
|
|
const URL_REGEX_URL = 'https://regexr.com/61bqd';
|
|
|
|
export const parseWikiContext = (val: string) => {
|
|
const matches = val.match(WIKI_REGEX);
|
|
if (matches === null) {
|
|
return undefined;
|
|
}
|
|
const sub = (matches.groups as any).subreddit as string;
|
|
return {
|
|
wiki: (matches.groups as any).url as string,
|
|
subreddit: sub === '' ? undefined : parseSubredditName(sub)
|
|
};
|
|
}
|
|
|
|
export const parseExternalUrl = (val: string) => {
|
|
const matches = val.match(URL_REGEX);
|
|
if (matches === null) {
|
|
return undefined;
|
|
}
|
|
return (matches.groups as any).url as string;
|
|
}
|
|
|
|
export interface RetryOptions {
|
|
maxRequestRetry: number,
|
|
maxOtherRetry: number,
|
|
}
|
|
|
|
export const createRetryHandler = (opts: RetryOptions, logger: Logger) => {
|
|
const {maxRequestRetry, maxOtherRetry} = opts;
|
|
|
|
let timeoutCount = 0;
|
|
let otherRetryCount = 0;
|
|
let lastErrorAt: Dayjs | undefined;
|
|
|
|
return async (err: any): Promise<boolean> => {
|
|
if (lastErrorAt !== undefined && dayjs().diff(lastErrorAt, 'minute') >= 3) {
|
|
// if its been longer than 5 minutes since last error clear counters
|
|
timeoutCount = 0;
|
|
otherRetryCount = 0;
|
|
}
|
|
|
|
lastErrorAt = dayjs();
|
|
|
|
if(err.name === 'RequestError' || err.name === 'StatusCodeError') {
|
|
if (err.statusCode === undefined || ([401, 500, 503, 502, 504, 522].includes(err.statusCode))) {
|
|
timeoutCount++;
|
|
if (timeoutCount > maxRequestRetry) {
|
|
logger.error(`Reddit request error retries (${timeoutCount}) exceeded max allowed (${maxRequestRetry})`);
|
|
return false;
|
|
}
|
|
// 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`);
|
|
await sleep(ms);
|
|
return true;
|
|
|
|
} else {
|
|
return false;
|
|
}
|
|
} else {
|
|
// linear backoff
|
|
otherRetryCount++;
|
|
if (maxOtherRetry < otherRetryCount) {
|
|
return false;
|
|
}
|
|
const ms = (4 * 1000) * otherRetryCount;
|
|
logger.warn(`Non-request error occurred. Will wait ${formatNumber(ms / 1000)} seconds before retrying`);
|
|
await sleep(ms);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
const LABELS_REGEX: RegExp = /(\[.+?])*/g;
|
|
export const parseLabels = (log: string): string[] => {
|
|
return Array.from(log.matchAll(LABELS_REGEX), m => m[0]).map(x => x.substring(1, x.length - 1));
|
|
}
|
|
|
|
export const parseALogName = (reg: RegExp) => (val: string): string | undefined => {
|
|
const matches = val.match(reg);
|
|
if (matches === null) {
|
|
return undefined;
|
|
}
|
|
return matches[1] as string;
|
|
}
|
|
|
|
const SUBREDDIT_NAME_LOG_REGEX: RegExp = /{(.+?)}/;
|
|
export const parseSubredditLogName = parseALogName(SUBREDDIT_NAME_LOG_REGEX);
|
|
export const parseSubredditLogInfoName = (logInfo: LogInfo) => logInfo.subreddit;
|
|
const BOT_NAME_LOG_REGEX: RegExp = /~(.+?)~/;
|
|
export const parseBotLogName = parseALogName(BOT_NAME_LOG_REGEX);
|
|
const INSTANCE_NAME_LOG_REGEX: RegExp = /\|(.+?)\|/;
|
|
export const parseInstanceLogName = parseALogName(INSTANCE_NAME_LOG_REGEX);
|
|
export const parseInstanceLogInfoName = (logInfo: LogInfo) => logInfo.instance;
|
|
|
|
export const LOG_LEVEL_REGEX: RegExp = /\s*(debug|warn|info|error|verbose)\s*:/i
|
|
export const isLogLineMinLevel = (log: string | LogInfo, minLevelText: string): boolean => {
|
|
// @ts-ignore
|
|
const minLevel = logLevels[minLevelText];
|
|
let level: number;
|
|
|
|
if(typeof log === 'string') {
|
|
const lineLevelMatch = log.match(LOG_LEVEL_REGEX)
|
|
if (lineLevelMatch === null) {
|
|
return false;
|
|
}
|
|
// @ts-ignore
|
|
level = logLevels[lineLevelMatch[1]];
|
|
} else {
|
|
const lineLevelMatch = log.level;
|
|
// @ts-ignore
|
|
level = logLevels[lineLevelMatch];
|
|
}
|
|
return level <= minLevel;
|
|
}
|
|
|
|
// https://regexr.com/3e6m0
|
|
const HYPERLINK_REGEX: RegExp = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/;
|
|
export const formatLogLineToHtml = (log: string | LogInfo) => {
|
|
const val = typeof log === 'string' ? log : log[MESSAGE];
|
|
const logContent = Autolinker.link(val, {
|
|
email: false,
|
|
phone: false,
|
|
mention: false,
|
|
hashtag: false,
|
|
stripPrefix: false,
|
|
sanitizeHtml: true,
|
|
})
|
|
.replace(/(\s*debug\s*):/i, '<span class="debug text-pink-400">$1</span>:')
|
|
.replace(/(\s*warn\s*):/i, '<span class="warn text-yellow-400">$1</span>:')
|
|
.replace(/(\s*info\s*):/i, '<span class="info text-blue-300">$1</span>:')
|
|
.replace(/(\s*error\s*):/i, '<span class="error text-red-400">$1</span>:')
|
|
.replace(/(\s*verbose\s*):/i, '<span class="error text-purple-400">$1</span>:')
|
|
.replaceAll('\n', '<br />');
|
|
//.replace(HYPERLINK_REGEX, '<a target="_blank" href="$&">$&</a>');
|
|
return `<div class="logLine">${logContent}</div>`
|
|
}
|
|
|
|
export type LogEntry = [number, LogInfo];
|
|
export interface LogOptions {
|
|
limit: number,
|
|
level: string,
|
|
sort: 'ascending' | 'descending',
|
|
operator?: boolean,
|
|
user?: string,
|
|
allLogsParser?: Function
|
|
allLogName?: string
|
|
}
|
|
|
|
export const filterLogBySubreddit = (logs: Map<string, LogEntry[]>, validLogCategories: string[] = [], options: LogOptions): Map<string, string[]> => {
|
|
const {
|
|
limit,
|
|
level,
|
|
sort,
|
|
operator = false,
|
|
user,
|
|
allLogsParser = parseSubredditLogInfoName,
|
|
allLogName = 'app'
|
|
} = options;
|
|
|
|
// get map of valid logs categories
|
|
const validSubMap: Map<string, LogEntry[]> = new Map();
|
|
for(const [k, v] of logs) {
|
|
if(validLogCategories.includes(k)) {
|
|
validSubMap.set(k, v);
|
|
}
|
|
}
|
|
|
|
// derive 'all'
|
|
let allLogs = (logs.get(allLogName) || []);
|
|
if(!operator) {
|
|
// if user is not an operator then we want to filter allLogs to only logs that include categories they can access
|
|
if(user === undefined) {
|
|
allLogs = [];
|
|
} else {
|
|
allLogs.filter(([time, l]) => {
|
|
const sub = allLogsParser(l);
|
|
return sub !== undefined && sub.includes(user);
|
|
});
|
|
}
|
|
}
|
|
// then append all other logs to all logs
|
|
// -- this is fine because we sort and truncate all logs just below this anyway
|
|
allLogs = Array.from(validSubMap.values()).reduce((acc, logs) => {
|
|
return acc.concat(logs);
|
|
},allLogs);
|
|
|
|
validSubMap.set('all', allLogs);
|
|
|
|
const sortFunc = sort === 'ascending' ? (a: LogEntry, b: LogEntry) => a[0] - b[0] : (a: LogEntry, b: LogEntry) => b[0] - a[0];
|
|
|
|
const preparedMap: Map<string, string[]> = new Map();
|
|
// iterate each entry and
|
|
// sort, filter by level, slice to limit, then map to html string
|
|
for(const [k,v] of validSubMap.entries()) {
|
|
let preparedEntries = v.filter(([time, l]) => isLogLineMinLevel(l, level));
|
|
preparedEntries.sort(sortFunc);
|
|
preparedMap.set(k, preparedEntries.slice(0, limit + 1).map(([time, l]) => formatLogLineToHtml(l)));
|
|
}
|
|
|
|
|
|
return preparedMap;
|
|
}
|
|
|
|
export const logLevels = {
|
|
error: 0,
|
|
warn: 1,
|
|
info: 2,
|
|
http: 3,
|
|
verbose: 4,
|
|
debug: 5,
|
|
trace: 5,
|
|
silly: 6
|
|
};
|
|
|
|
export const pollingInfo = (opt: PollingOptionsStrong) => {
|
|
return `${opt.pollOn.toUpperCase()} every ${opt.interval} seconds${opt.delayUntil !== undefined ? ` | wait until Activity is ${opt.delayUntil} seconds old` : ''} | maximum of ${opt.limit} Activities`
|
|
}
|
|
|
|
export const totalFromMapStats = (val: Map<any, number>): number => {
|
|
return Array.from(val.entries()).reduce((acc: number, [k, v]) => {
|
|
return acc + v;
|
|
}, 0);
|
|
}
|
|
|
|
export const permissions = [
|
|
'identity',
|
|
'history',
|
|
'read',
|
|
'modcontributors',
|
|
'modflair',
|
|
'modlog',
|
|
'modmail',
|
|
'privatemessages',
|
|
'modposts',
|
|
'modself',
|
|
'mysubreddits',
|
|
'report',
|
|
'submit',
|
|
'wikiread',
|
|
'wikiedit',
|
|
];
|
|
|
|
export const boolToString = (val: boolean): string => {
|
|
return val ? 'Yes' : 'No';
|
|
}
|
|
|
|
export const isRedditMedia = (act: Submission): boolean => {
|
|
return act.is_reddit_media_domain || act.is_video || ['v.redd.it','i.redd.it'].includes(act.domain);
|
|
}
|
|
|
|
export const isExternalUrlSubmission = (act: Comment | Submission): boolean => {
|
|
return asSubmission(act) && !act.is_self && !isRedditMedia(act);
|
|
}
|
|
|
|
export const parseStringToRegex = (val: string, defaultFlags?: string): RegExp | undefined => {
|
|
const result = ReReg.exec(val);
|
|
if (result === null) {
|
|
return undefined;
|
|
}
|
|
// index 0 => full string
|
|
// index 1 => regex without flags and forward slashes
|
|
// index 2 => flags
|
|
const flags = result[2] === '' ? (defaultFlags || '') : result[2];
|
|
return new RegExp(result[1], flags);
|
|
}
|
|
|
|
export const parseRegex = (reg: RegExp, val: string): RegExResult => {
|
|
|
|
if(reg.global) {
|
|
const g = Array.from(val.matchAll(reg));
|
|
const global = g.map(x => {
|
|
return {
|
|
match: x[0],
|
|
groups: x.slice(1),
|
|
named: x.groups,
|
|
}
|
|
});
|
|
return {
|
|
matched: g.length > 0,
|
|
matches: g.length > 0 ? g.map(x => x[0]) : [],
|
|
global: g.length > 0 ? global : [],
|
|
};
|
|
}
|
|
|
|
const m = val.match(reg)
|
|
return {
|
|
matched: m !== null,
|
|
matches: m !== null ? m.slice(0) : [],
|
|
global: [],
|
|
}
|
|
}
|
|
|
|
export const isStrongSubredditState = (value: SubredditState | StrongSubredditState) => {
|
|
return value.name === undefined || value.name instanceof RegExp;
|
|
}
|
|
|
|
export const asStrongSubredditState = (value: any): value is StrongSubredditState => {
|
|
return isStrongSubredditState(value);
|
|
}
|
|
|
|
export interface StrongSubredditStateOptions {
|
|
defaultFlags?: string
|
|
generateDescription?: boolean
|
|
}
|
|
|
|
export const toStrongSubredditState = (s: SubredditState, opts?: StrongSubredditStateOptions): StrongSubredditState => {
|
|
const {defaultFlags, generateDescription = false} = opts || {};
|
|
const {name: nameVal, stateDescription} = s;
|
|
|
|
let nameReg: RegExp | undefined;
|
|
if (nameVal !== undefined) {
|
|
if (!(nameVal instanceof RegExp)) {
|
|
nameReg = parseStringToRegex(nameVal, defaultFlags);
|
|
if (nameReg === undefined) {
|
|
nameReg = new RegExp(parseSubredditName(nameVal), defaultFlags);
|
|
}
|
|
} else {
|
|
nameReg = nameVal;
|
|
}
|
|
}
|
|
const strongState = {
|
|
...s,
|
|
name: nameReg
|
|
};
|
|
|
|
if (generateDescription && stateDescription === undefined) {
|
|
strongState.stateDescription = objectToStringSummary(strongState);
|
|
}
|
|
|
|
return strongState;
|
|
}
|
|
|
|
export async function readConfigFile(path: string, opts: any) {
|
|
const {log, throwOnNotFound = true} = opts;
|
|
try {
|
|
await promises.access(path, constants.R_OK);
|
|
const data = await promises.readFile(path);
|
|
const [configObj, jsonErr, yamlErr] = parseFromJsonOrYamlToObject(data as unknown as string);
|
|
if(configObj !== undefined) {
|
|
return configObj as object;
|
|
}
|
|
log.error(`Could not parse wiki page contents as JSON or YAML:`);
|
|
log.error(jsonErr);
|
|
log.error(yamlErr);
|
|
throw new SimpleError('Could not parse wiki page contents as JSON or YAML');
|
|
} catch (e) {
|
|
const {code} = e;
|
|
if (code === 'ENOENT') {
|
|
if (throwOnNotFound) {
|
|
if (log) {
|
|
log.warn('No file found at given path', {filePath: path});
|
|
}
|
|
throw e;
|
|
} else {
|
|
return;
|
|
}
|
|
} else if (log) {
|
|
log.warn(`Encountered error while parsing file`, {filePath: path});
|
|
log.error(e);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// export function isObject(item: any): boolean {
|
|
// return (item && typeof item === 'object' && !Array.isArray(item));
|
|
// }
|
|
|
|
export const overwriteMerge = (destinationArray: any[], sourceArray: any[], options: any): any[] => sourceArray;
|
|
|
|
export const removeUndefinedKeys = (obj: any) => {
|
|
let newObj: any = {};
|
|
Object.keys(obj).forEach((key) => {
|
|
if(Array.isArray(obj[key])) {
|
|
newObj[key] = obj[key];
|
|
} else if (obj[key] === Object(obj[key])) {
|
|
newObj[key] = removeUndefinedKeys(obj[key]);
|
|
} else if (obj[key] !== undefined) {
|
|
newObj[key] = obj[key];
|
|
}
|
|
});
|
|
if(Object.keys(newObj).length === 0) {
|
|
return undefined;
|
|
}
|
|
Object.keys(newObj).forEach(key => {
|
|
if(newObj[key] === undefined || (null !== newObj[key] && typeof newObj[key] === 'object' && Object.keys(newObj[key]).length === 0)) {
|
|
delete newObj[key]
|
|
}
|
|
});
|
|
//Object.keys(newObj).forEach(key => newObj[key] === undefined || newObj[key] && delete newObj[key])
|
|
return newObj;
|
|
}
|
|
|
|
const timestampArr = () => {
|
|
const arr: number[] = [];
|
|
arr.length = 50;
|
|
return arr;
|
|
}
|
|
|
|
const statMetricCache = () => {
|
|
return cacheManager.caching({store: 'memory', max: 50, ttl: 0});
|
|
}
|
|
|
|
export const cacheStats = (): ResourceStats => {
|
|
return {
|
|
author: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
authorCrit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
itemCrit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
subredditCrit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
content: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
userNotes: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
submission: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
comment: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
subreddit: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0},
|
|
commentCheck: {requests: 0, miss: 0, identifierRequestCount: statMetricCache(), requestTimestamps: timestampArr(), averageTimeBetweenHits: 'N/A', identifierAverageHit: 0}
|
|
};
|
|
}
|
|
|
|
export const buildCacheOptionsFromProvider = (provider: CacheProvider | any): CacheOptions => {
|
|
if(typeof provider === 'string') {
|
|
return {
|
|
store: provider as CacheProvider,
|
|
...cacheOptDefaults
|
|
}
|
|
}
|
|
return {
|
|
store: 'memory',
|
|
...cacheOptDefaults,
|
|
...provider,
|
|
}
|
|
}
|
|
|
|
export const createCacheManager = (options: CacheOptions): Cache => {
|
|
const {store, max, ttl = 60, host = 'localhost', port, auth_pass, db, ...rest} = options;
|
|
switch (store) {
|
|
case 'none':
|
|
return cacheManager.caching({store: 'none', max, ttl});
|
|
case 'redis':
|
|
return cacheManager.caching({
|
|
store: redisStore,
|
|
host,
|
|
port,
|
|
auth_pass,
|
|
db,
|
|
ttl,
|
|
...rest,
|
|
});
|
|
case 'memory':
|
|
default:
|
|
//return cacheManager.caching({store: 'memory', max, ttl});
|
|
return cacheManager.caching({store: {create: createMemoryStore}, max, ttl, shouldCloneBeforeSet: false});
|
|
}
|
|
}
|
|
|
|
export const randomId = () => crypto.randomBytes(20).toString('hex');
|
|
|
|
export const intersect = (a: Array<any>, b: Array<any>) => {
|
|
const setA = new Set(a);
|
|
const setB = new Set(b);
|
|
const intersection = new Set([...setA].filter(x => setB.has(x)));
|
|
return Array.from(intersection);
|
|
}
|
|
|
|
export const snooLogWrapper = (logger: Logger) => {
|
|
return {
|
|
warn: (...args: any[]) => logger.warn(args.slice(0, 2).join(' '), [args.slice(2)]),
|
|
debug: (...args: any[]) => logger.debug(args.slice(0, 2).join(' '), [args.slice(2)]),
|
|
info: (...args: any[]) => logger.info(args.slice(0, 2).join(' '), [args.slice(2)]),
|
|
trace: (...args: any[]) => logger.debug(args.slice(0, 2).join(' '), [args.slice(2)]),
|
|
}
|
|
}
|
|
|
|
export const isScopeError = (err: any): boolean => {
|
|
if(typeof err === 'object' && err.name === 'StatusCodeError' && err.response !== undefined) {
|
|
const authHeader = err.response.headers['www-authenticate'];
|
|
return authHeader !== undefined && authHeader.includes('insufficient_scope');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export const isStatusError = (err: any): err is StatusCodeError => {
|
|
return typeof err === 'object' && err.name === 'StatusCodeError' && err.response !== undefined;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|
|
export const asSubmission = (value: any): value is Submission => {
|
|
return isSubmission(value);
|
|
}
|
|
|
|
/**
|
|
* Serialized activities store subreddit and user properties as their string representations (instead of proxy)
|
|
* */
|
|
export const getActivitySubredditName = (activity: any): string => {
|
|
if(typeof activity.subreddit === 'string') {
|
|
return activity.subreddit;
|
|
}
|
|
return activity.subreddit.display_name;
|
|
}
|
|
|
|
/**
|
|
* Serialized activities store subreddit and user properties as their string representations (instead of proxy)
|
|
* */
|
|
export const getActivityAuthorName = (author: RedditUser | string): string => {
|
|
if(typeof author === 'string') {
|
|
return author;
|
|
}
|
|
return author.name;
|
|
}
|
|
|
|
export const buildCachePrefix = (parts: any[]): string => {
|
|
const prefix = parts.filter(x => typeof x === 'string' && x !== '').map(x => x.trim()).map(x => x.split(':')).flat().filter(x => x !== '').join(':')
|
|
if(prefix !== '') {
|
|
return `${prefix}:`;
|
|
}
|
|
return prefix;
|
|
}
|
|
|
|
export const objectToStringSummary = (obj: object): string => {
|
|
const parts = [];
|
|
for(const [key, val] of Object.entries(obj)) {
|
|
parts.push(`${key}: ${val}`);
|
|
}
|
|
return parts.join(' | ');
|
|
}
|
|
|
|
/**
|
|
* Returns the index of the last element in the array where predicate is true, and -1
|
|
* otherwise.
|
|
* @param array The source array to search in
|
|
* @param predicate find calls predicate once for each element of the array, in descending
|
|
* order, until it finds one where predicate returns true. If such an element is found,
|
|
* findLastIndex immediately returns that element index. Otherwise, findLastIndex returns -1.
|
|
*
|
|
* @see https://stackoverflow.com/a/53187807/1469797
|
|
*/
|
|
export function findLastIndex<T>(array: Array<T>, predicate: (value: T, index: number, obj: T[]) => boolean): number {
|
|
let l = array.length;
|
|
while (l--) {
|
|
if (predicate(array[l], l, array))
|
|
return l;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
export const parseRuleResultsToMarkdownSummary = (ruleResults: RuleResult[]): string => {
|
|
const results = ruleResults.map((y: any) => {
|
|
const {triggered, result, name, ...restY} = y;
|
|
let t = triggeredIndicator(false);
|
|
if(triggered === null) {
|
|
t = 'Skipped';
|
|
} else if(triggered === true) {
|
|
t = triggeredIndicator(true);
|
|
}
|
|
return `* ${name} - ${t} - ${result || '-'}`;
|
|
});
|
|
return results.join('\r\n');
|
|
}
|