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 */ export const groupBy = (keys: (keyof T)[], opts: groupByOptions = {}) => (array: T[]): Record => { 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) }; // 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); // 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*(?>|>=|<|<=)\s*(?\d+)(?\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*(?>|>=|<|<=)\s*(?\d+)\s*(?%?)(?.*)$/ 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*(?