From 4bef85e1e47735a527ef04bf1663490f94c55cb4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 21 Jun 2021 11:40:04 -0400 Subject: [PATCH] Refactor most thresholds to use comparison operators (like automod) --- src/Common/interfaces.ts | 2 +- src/Rule/HistoryRule.ts | 16 ++-- src/Rule/RecentActivityRule.ts | 92 +++++++++---------- src/Rule/SubmissionRule/AttributionRule.ts | 32 +++---- src/Rule/SubmissionRule/RepeatActivityRule.ts | 55 ++++++++--- src/Rule/index.ts | 50 +++++++--- src/Schema/App.json | 56 ++++------- src/Schema/Rule.json | 56 ++++------- src/Schema/RuleSet.json | 56 ++++------- src/Utils/SnoowrapUtils.ts | 23 +++-- src/util.ts | 54 +++++++---- 11 files changed, 249 insertions(+), 243 deletions(-) diff --git a/src/Common/interfaces.ts b/src/Common/interfaces.ts index 208aa8c..4b8cb95 100644 --- a/src/Common/interfaces.ts +++ b/src/Common/interfaces.ts @@ -404,7 +404,7 @@ export interface ManagerOptions { /** * A string containing a comparison operator and a value to compare against * - * The syntax is `< OR > OR <= OR >=] [number][?percent sign]` + * The syntax is `(< OR > OR <= OR >=) [percent sign]` * * * EX `> 100` => greater than 100 * * EX `<= 75%` => less than or equal to 75% diff --git a/src/Rule/HistoryRule.ts b/src/Rule/HistoryRule.ts index f51ff00..4c301f3 100644 --- a/src/Rule/HistoryRule.ts +++ b/src/Rule/HistoryRule.ts @@ -4,7 +4,7 @@ import {Rule, RuleJSONConfig, RuleOptions, RuleResult} from "./index"; import Submission from "snoowrap/dist/objects/Submission"; import {getAuthorActivities} from "../Utils/SnoowrapUtils"; import dayjs from "dayjs"; -import {comparisonTextOp, formatNumber, parseGenericComparison, percentFromString} from "../util"; +import {comparisonTextOp, formatNumber, parseGenericValueOrPercentComparison, percentFromString} from "../util"; export interface CommentThresholdCriteria extends ThresholdCriteria { /** @@ -23,7 +23,7 @@ export interface HistoryCriteria { /** * A string containing a comparison operator and a value to compare submissions against * - * The syntax is `[< OR > OR <= OR >=] [number][?percent sign]` + * The syntax is `(< OR > OR <= OR >=) [percent sign]` * * * EX `> 100` => greater than 100 submissions * * EX `<= 75%` => submissions are equal to or less than 75% of all Activities @@ -34,12 +34,12 @@ export interface HistoryCriteria { /** * A string containing a comparison operator and a value to compare comments against * - * The syntax is `[< OR > OR <= OR >=] [number][?percent sign]` + * The syntax is `(< OR > OR <= OR >=) [percent sign] [OP]` * * * EX `> 100` => greater than 100 comments * * EX `<= 75%` => comments are equal to or less than 75% of all Activities * - * If your string also contains the text `OP` somewhere **after** `[number][?percent sign]`...: + * If your string also contains the text `OP` somewhere **after** `[percent sign]`...: * * * EX `> 100 OP` => greater than 100 comments as OP * * EX `<= 25% as OP` => Comments as OP were less then or equal to 25% of **all Comments** @@ -131,7 +131,7 @@ export class HistoryRule extends Rule { let commentTrigger = undefined; if(comment !== undefined) { - const {operator, value, isPercent, extra = ''} = parseGenericComparison(comment); + const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(comment); const asOp = extra.toLowerCase().includes('op'); if(isPercent) { const per = value / 100; @@ -151,7 +151,7 @@ export class HistoryRule extends Rule { let submissionTrigger = undefined; if(submission !== undefined) { - const {operator, value, isPercent} = parseGenericComparison(submission); + const {operator, value, isPercent} = parseGenericValueOrPercentComparison(submission); if(isPercent) { const per = value / 100; submissionTrigger = comparisonTextOp(submissionTotal / activityTotal, operator, per); @@ -218,14 +218,14 @@ export class HistoryRule extends Rule { let submissionSummary; let commentSummary; if(submission !== undefined) { - const {operator, value, isPercent, displayText} = parseGenericComparison(submission); + const {operator, value, isPercent, displayText} = parseGenericValueOrPercentComparison(submission); const suffix = !isPercent ? 'Items' : `(${formatNumber((submissionTotal/activityTotal)*100)}%) of ${activityTotal} Total`; submissionSummary = `Submissions (${submissionTotal}) were ${displayText} ${suffix}`; data.submissionSummary = submissionSummary; thresholdSummary.push(submissionSummary); } if(comment !== undefined) { - const {operator, value, isPercent, displayText, extra = ''} = parseGenericComparison(comment); + const {operator, value, isPercent, displayText, extra = ''} = parseGenericValueOrPercentComparison(comment); const asOp = extra.toLowerCase().includes('op'); const totalType = asOp ? 'Comments' : 'Activities' const countType = asOp ? 'Comments as OP' : 'Comments'; diff --git a/src/Rule/RecentActivityRule.ts b/src/Rule/RecentActivityRule.ts index 0a6557a..de63e4a 100644 --- a/src/Rule/RecentActivityRule.ts +++ b/src/Rule/RecentActivityRule.ts @@ -1,7 +1,7 @@ import {Rule, RuleJSONConfig, RuleOptions, RulePremise, RuleResult} from "./index"; import {Comment, VoteableContent} from "snoowrap"; import Submission from "snoowrap/dist/objects/Submission"; -import {activityWindowText, parseUsableLinkIdentifier} from "../util"; +import {activityWindowText, comparisonTextOp, parseGenericValueOrPercentComparison, parseUsableLinkIdentifier} from "../util"; import { ActivityWindow, ActivityWindowCriteria, @@ -59,7 +59,6 @@ export class RecentActivityRule extends Rule { break; } - let viableActivity = activities; if (this.useSubmissionAsReference) { if (!(item instanceof Submission)) { @@ -84,53 +83,51 @@ export class RecentActivityRule extends Rule { grouped[s] = (grouped[s] || []).concat(activity); return grouped; }, {} as Record); - let triggeredPerSub = []; + + let totalTriggeredOn; for (const triggerSet of this.thresholds) { - triggeredPerSub = []; let currCount = 0; - let presentSubs = []; - const {count: subCount = 1, totalCount = 1, subreddits = []} = triggerSet; + let presentSubs; + const {threshold = '>= 1', subreddits = []} = triggerSet; for (const sub of subreddits) { const isub = sub.toLowerCase(); const {[isub]: tSub = []} = groupedActivity; - if(tSub.length > 0) { + if (tSub.length > 0) { currCount += tSub.length; - presentSubs.push(sub); - if (subCount !== undefined && tSub.length >= subCount) { - triggeredPerSub.push({subreddit: sub, count: tSub.length, threshold: subCount}); + if(presentSubs === undefined) { + presentSubs = []; } + presentSubs.push(sub); } } - if(totalCount !== undefined && currCount >= totalCount) { - totalTriggeredOn = {subreddits: presentSubs, count: currCount, threshold: totalCount}; + const {operator, value, isPercent} = parseGenericValueOrPercentComparison(threshold); + if (threshold !== undefined) { + if (isPercent) { + if (comparisonTextOp(currCount / viableActivity.length, operator, value / 100)) { + totalTriggeredOn = {subreddits: presentSubs || subreddits, count: currCount, threshold}; + } + } else if (comparisonTextOp(currCount, operator, value)) { + totalTriggeredOn = {subreddits: presentSubs || subreddits, count: currCount, threshold}; + } } // if either trigger condition is hit end the iteration early - if(triggeredPerSub.length > 0 || totalTriggeredOn !== undefined) { + if (totalTriggeredOn !== undefined) { break; } } - if (triggeredPerSub.length > 0 || totalTriggeredOn !== undefined) { + if (totalTriggeredOn !== undefined) { let resultArr = []; const data: any = {}; - if(triggeredPerSub.length > 0) { - data.perSubCount = triggeredPerSub.length; - data.perSubTotal = triggeredPerSub.reduce((acc, x) => acc + x.count, 0); - data.perSubSubredditsSummary = triggeredPerSub.map(x => x.subreddit).join(', '); - data.perSubSummary = triggeredPerSub.map(x => `${x.subreddit}(${x.count})`).join(', '); - data.perSubThreshold = triggeredPerSub[0].threshold; - resultArr.push(`${triggeredPerSub.length} subs have >${triggeredPerSub[0].threshold} activities (${data.perSubTotal} Total)`); - } - if(totalTriggeredOn !== undefined) { - data.totalCount = totalTriggeredOn.count; - data.totalSubredditsCount = totalTriggeredOn.subreddits.length; - data.totalSubredditsSummary = totalTriggeredOn.subreddits.join(', ') - data.totalThreshold = totalTriggeredOn.threshold; - data.totalSummary = `${data.totalCount} (>${totalTriggeredOn.threshold}) activities over ${totalTriggeredOn.subreddits.length} subreddits`; - resultArr.push(data.totalSummary); - } + data.totalCount = totalTriggeredOn.count; + data.totalSubredditsCount = totalTriggeredOn.subreddits.length; + data.totalSubredditsSummary = totalTriggeredOn.subreddits.join(', ') + data.totalThreshold = totalTriggeredOn.threshold; + data.totalSummary = `${data.totalCount} (${totalTriggeredOn.threshold}) activities over ${totalTriggeredOn.subreddits.length} subreddits`; + resultArr.push(data.totalSummary); + let summary; - if(resultArr.length === 2) { + if (resultArr.length === 2) { // need a shortened summary summary = `${data.perSubCount} per-sub triggers (${data.perSubThreshold}) and ${data.totalCount} total (${data.totalThreshold})` } else { @@ -142,11 +139,11 @@ export class RecentActivityRule extends Rule { result, data: { window: typeof this.window === 'number' ? `${activities.length} Items` : activityWindowText(viableActivity), - triggeredOn: triggeredPerSub, + //triggeredOn: triggeredPerSub, summary, - subSummary: data.totalSubredditsSummary|| data.perSubSubredditsSummary, - subCount: data.totalSubredditsCount || data.perSubCount, - totalCount: data.totalCount || data.perSubTotal + subSummary: data.totalSubredditsSummary, + subCount: data.totalSubredditsCount, + totalCount: data.totalCount } })]]); } @@ -163,19 +160,20 @@ export class RecentActivityRule extends Rule { * */ export interface SubThreshold extends SubredditCriteria { /** - * The number of activities in each subreddit from the list that will trigger this rule - * @minimum 1 - * @default 1 - * @examples [1] + * A string containing a comparison operator and a value to compare recent activities against + * + * The syntax is `(< OR > OR <= OR >=) [percent sign]` + * + * * EX `> 3` => greater than 3 activities found in the listed subreddits + * * EX `<= 75%` => number of Activities in the subreddits listed are equal to or less than 75% of all Activities + * + * **Note:** If you use percentage comparison here as well as `useSubmissionAsReference` then "all Activities" is only pertains to Activities that had the Link of the Submission, rather than all Activities from this window. + * + * @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$ + * @default ">= 1" + * @examples [">= 1"] * */ - count?: number, - /** - * The total number of activities across all listed subreddits that will trigger this rule - * @minimum 1 - * @default 1 - * @examples [1] - * */ - totalCount?: number + threshold?: string } interface RecentActivityConfig extends ActivityWindow, ReferenceSubmission { diff --git a/src/Rule/SubmissionRule/AttributionRule.ts b/src/Rule/SubmissionRule/AttributionRule.ts index 8bfdf6c..008d7ab 100644 --- a/src/Rule/SubmissionRule/AttributionRule.ts +++ b/src/Rule/SubmissionRule/AttributionRule.ts @@ -4,18 +4,22 @@ import {RuleOptions, RuleResult} from "../index"; import Submission from "snoowrap/dist/objects/Submission"; import {getAttributionIdentifier} from "../../Utils/SnoowrapUtils"; import dayjs from "dayjs"; +import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../../util"; export interface AttributionCriteria { /** - * The number or percentage to trigger this rule at + * A string containing a comparison operator and a value to compare comments against * - * * If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at - * * If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger + * The syntax is `(< OR > OR <= OR >=) [percent sign]` * - * @default 10% + * * EX `> 12` => greater than 12 activities originate from same attribution + * * EX `<= 10%` => less than 10% of all Activities have the same attribution + * + * @pattern ^\s*(>|>=|<|<=)\s*(\d+)\s*(%?)(.*)$ + * @default "> 10%" * */ - threshold: number | string + threshold: string window: ActivityWindowType /** * What activities to use for total count when determining what percentage an attribution comprises @@ -107,12 +111,8 @@ export class AttributionRule extends SubmissionRule { for (const criteria of this.criteria) { - const {threshold, window, thresholdOn = 'all', minActivityCount = 5} = criteria; - - let percentVal; - if (typeof threshold === 'string') { - percentVal = Number.parseInt(threshold.replace('%', '')) / 100; - } + const {threshold = '> 10%', window, thresholdOn = 'all', minActivityCount = 10} = criteria; + const {operator, value, isPercent, extra = ''} = parseGenericValueOrPercentComparison(threshold); let activities = thresholdOn === 'submissions' ? await this.resources.getAuthorSubmissions(item.author, {window: window}) : await this.resources.getAuthorActivities(item.author, {window: window}); activities = activities.filter(act => { @@ -172,11 +172,11 @@ export class AttributionRule extends SubmissionRule { let triggeredDomains = []; for (const [domain, subCount] of aggregatedSubmissions) { let triggered = false; - if (percentVal !== undefined) { - - triggered = percentVal <= subCount / activityTotal; - } else if (subCount >= threshold) { - triggered = true; + if(isPercent) { + triggered = comparisonTextOp(subCount / activityTotal, operator, (value/100)); + } + else { + triggered = comparisonTextOp(subCount, operator, value); } if (triggered) { diff --git a/src/Rule/SubmissionRule/RepeatActivityRule.ts b/src/Rule/SubmissionRule/RepeatActivityRule.ts index 847a7cc..1292aef 100644 --- a/src/Rule/SubmissionRule/RepeatActivityRule.ts +++ b/src/Rule/SubmissionRule/RepeatActivityRule.ts @@ -1,7 +1,12 @@ import {SubmissionRule, SubmissionRuleJSONConfig} from "./index"; import {RuleOptions, RuleResult} from "../index"; import {Comment} from "snoowrap"; -import {activityWindowText, parseUsableLinkIdentifier as linkParser} from "../../util"; +import { + activityWindowText, + comparisonTextOp, + parseGenericValueComparison, + parseUsableLinkIdentifier as linkParser +} from "../../util"; import {ActivityWindow, ActivityWindowType, ReferenceSubmission} from "../../Common/interfaces"; import Submission from "snoowrap/dist/objects/Submission"; import dayjs from "dayjs"; @@ -33,7 +38,7 @@ const getActivityIdentifier = (activity: (Submission | Comment), length = 200) = } export class RepeatActivityRule extends SubmissionRule { - threshold: number; + threshold: string; window: ActivityWindowType; gapAllowance?: number; useSubmissionAsReference: boolean; @@ -44,7 +49,7 @@ export class RepeatActivityRule extends SubmissionRule { constructor(options: RepeatActivityOptions) { super(options); const { - threshold = 5, + threshold = '> 5', window = 100, gapAllowance, useSubmissionAsReference = true, @@ -139,6 +144,10 @@ export class RepeatActivityRule extends SubmissionRule { applicableGroupedActivities.set(getActivityIdentifier(item), referenceSubmissions || []) } + const {operator, value: thresholdValue} = parseGenericValueComparison(this.threshold); + const greaterThan = operator.includes('>'); + let allLessThan = true; + const identifiersSummary: SummaryData[] = []; for (let [key, value] of applicableGroupedActivities) { const summaryData = { @@ -150,23 +159,39 @@ export class RepeatActivityRule extends SubmissionRule { triggeringSetsMarkdown: [], }; for (let set of value) { - if (set.length >= this.threshold) { - // @ts-ignore - summaryData.triggeringSets.push(set); - summaryData.totalTriggeringSets++; - summaryData.largestTrigger = Math.max(summaryData.largestTrigger, set.length); - const md = set.map((x: (Comment | Submission)) => `[${x instanceof Submission ? x.title : getActivityIdentifier(x, 50)}](https://reddit.com${x.permalink}) in ${x.subreddit_name_prefixed} on ${dayjs(x.created_utc * 1000).utc().format()}`); - // @ts-ignore - summaryData.triggeringSetsMarkdown.push(md); + const test = comparisonTextOp(set.length, operator, thresholdValue); + if(test) { + // if(greaterThan) { + // @ts-ignore + summaryData.triggeringSets.push(set); + summaryData.totalTriggeringSets++; + summaryData.largestTrigger = Math.max(summaryData.largestTrigger, set.length); + const md = set.map((x: (Comment | Submission)) => `[${x instanceof Submission ? x.title : getActivityIdentifier(x, 50)}](https://reddit.com${x.permalink}) in ${x.subreddit_name_prefixed} on ${dayjs(x.created_utc * 1000).utc().format()}`); + // @ts-ignore + summaryData.triggeringSetsMarkdown.push(md); + // } + } else if(!greaterThan) { + allLessThan = false; } + // if ((test && greaterThan) || (!test && !greaterThan)) { + // // @ts-ignore + // summaryData.triggeringSets.push(set); + // summaryData.totalTriggeringSets++; + // summaryData.largestTrigger = Math.max(summaryData.largestTrigger, set.length); + // const md = set.map((x: (Comment | Submission)) => `[${x instanceof Submission ? x.title : getActivityIdentifier(x, 50)}](https://reddit.com${x.permalink}) in ${x.subreddit_name_prefixed} on ${dayjs(x.created_utc * 1000).utc().format()}`); + // // @ts-ignore + // summaryData.triggeringSetsMarkdown.push(md); + // } + } + if(greaterThan || (!greaterThan && allLessThan)) { + identifiersSummary.push(summaryData); } - identifiersSummary.push(summaryData); } const triggeringSummaries = identifiersSummary.filter(x => x.totalTriggeringSets > 0) if (triggeringSummaries.length > 0) { const largestRepeat = triggeringSummaries.reduce((acc, summ) => Math.max(summ.largestTrigger, acc), 0); - const result = `${triggeringSummaries.length} of ${identifiersSummary.length} unique items repeated >=${this.threshold} (threshold) times, largest repeat: ${largestRepeat}`; + const result = `${triggeringSummaries.length} of ${identifiersSummary.length} unique items repeated ${this.threshold} (threshold) times, largest repeat: ${largestRepeat}`; this.logger.verbose(result); return Promise.resolve([true, [this.getResult(true, { result, @@ -198,9 +223,9 @@ interface SummaryData { interface RepeatActivityConfig extends ActivityWindow, ReferenceSubmission { /** * The number of repeat submissions that will trigger the rule - * @default 5 + * @default ">= 5" * */ - threshold?: number, + threshold?: string, /** * The number of allowed non-identical Submissions between identical Submissions that can be ignored when checking against the threshold value * */ diff --git a/src/Rule/index.ts b/src/Rule/index.ts index cffa423..887cb7f 100644 --- a/src/Rule/index.ts +++ b/src/Rule/index.ts @@ -149,31 +149,51 @@ export class Author implements AuthorCriteria { export interface UserNoteCriteria { /** - * User Note type key + * User Note type key to search for * @examples ["spamwarn"] * */ type: string; /** * Number of occurrences of this type. Ignored if `search` is `current` - * @examples [1] - * @default 1 + * + * A string containing a comparison operator and/or a value to compare number of occurrences against + * + * The syntax is `(< OR > OR <= OR >=) [percent sign] [ascending|descending]` + * + * @examples [">= 1"] + * @default ">= 1" + * @pattern ^\s*(?>|>=|<|<=)\s*(?\d+)\s*(?%?)\s*(?asc.*|desc.*)*$ * */ - count?: number; + count?: string; /** - * * If `current` then only the most recent note is checked - * * If `consecutive` then `count` number of `type` notes must be found in a row, based on `order` direction - * * If `total` then `count` number of `type` must be found within all notes + * How to test the notes for this Author: + * + * ### current + * + * Only the most recent note is checked for `type` + * + * ### total + * + * The `count` comparison of `type` must be found within all notes + * + * * EX `count: > 3` => Must have more than 3 notes of `type`, total + * * EX `count: <= 25%` => Must have 25% or less of notes of `type`, total + * + * ### consecutive + * + * The `count` **number** of `type` notes must be found in a row. + * + * You may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending` + * + * * EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order + * * EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order + * * EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order + * * @examples ["current"] * @default current * */ search?: 'current' | 'consecutive' | 'total' - /** - * Time-based order to search Notes in for `consecutive` search - * @examples ["descending"] - * @default descending - * */ - order?: 'ascending' | 'descending' } /** @@ -231,7 +251,7 @@ export interface AuthorCriteria { /** * Test the age of the Author's account (when it was created) against this comparison * - * The syntax is `[< OR > OR <= OR >=] [number] [unit]` + * The syntax is `(< OR > OR <= OR >=) ` * * * EX `> 100 days` => Passes if Author's account is older than 100 days * * EX `<= 2 months` => Passes if Author's account is younger than or equal to 2 months @@ -248,7 +268,7 @@ export interface AuthorCriteria { /** * A duration and how to compare it against a value * - * The syntax is `[< OR > OR <= OR >=] [number] [unit]` EX `> 100 days`, `<= 2 months` + * The syntax is `(< OR > OR <= OR >=) ` EX `> 100 days`, `<= 2 months` * * * EX `> 100 days` => Passes if the date being compared is before 100 days ago * * EX `<= 2 months` => Passes if the date being compared is after or equal to 2 months diff --git a/src/Schema/App.json b/src/Schema/App.json index 41cc973..ea8aa65 100644 --- a/src/Schema/App.json +++ b/src/Schema/App.json @@ -98,12 +98,10 @@ "type": "string" }, "threshold": { - "default": "10%", - "description": "The number or percentage to trigger this rule at\n\n* If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger", - "type": [ - "string", - "number" - ] + "default": "> 10%", + "description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `[< OR > OR <= OR >=] [number][?percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution", + "pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$", + "type": "string" }, "thresholdOn": { "default": "all", @@ -1361,9 +1359,9 @@ "type": "string" }, "threshold": { - "default": 5, + "default": ">= 5", "description": "The number of repeat submissions that will trigger the rule", - "type": "number" + "type": "string" }, "useSubmissionAsReference": { "default": true, @@ -1491,15 +1489,6 @@ "description": "At least one count property must be present. If both are present then either can trigger the rule", "minProperties": 1, "properties": { - "count": { - "default": 1, - "description": "The number of activities in each subreddit from the list that will trigger this rule", - "examples": [ - 1 - ], - "minimum": 1, - "type": "number" - }, "subreddits": { "description": "A list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]", "examples": [ @@ -1514,14 +1503,14 @@ "minItems": 2, "type": "array" }, - "totalCount": { - "default": 1, - "description": "The total number of activities across all listed subreddits that will trigger this rule", + "threshold": { + "default": ">= 1", + "description": "A string containing a comparison operator and a value to compare recent activities against\n\nThe syntax is `[< OR > OR <= OR >=] [number][?percent sign]`\n\n* EX `> 3` => greater than 3 activities found in the listed subreddits\n* EX `<= 75%` => number of Activities in the subreddits listed are equal to or less than 75% of all Activities\n\n**Note:** If you use percentage comparison here as well as `useSubmissionAsReference` then \"all Activities\" is only pertains to Activities that had the Link of the Submission, rather than all Activities from this window.", "examples": [ - 1 + ">= 1" ], - "minimum": 1, - "type": "number" + "pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$", + "type": "string" } }, "required": [ @@ -1816,28 +1805,17 @@ "UserNoteCriteria": { "properties": { "count": { - "default": 1, - "description": "Number of occurrences of this type. Ignored if `search` is `current`", + "default": ">= 1", + "description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) [percent sign] [ascending|descending]`", "examples": [ 1 ], - "type": "number" - }, - "order": { - "default": "descending", - "description": "Time-based order to search Notes in for `consecutive` search", - "enum": [ - "ascending", - "descending" - ], - "examples": [ - "descending" - ], + "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?asc.*|desc.*)*$", "type": "string" }, "search": { "default": "current", - "description": "* If `current` then only the most recent note is checked\n* If `consecutive` then `count` number of `type` notes must be found in a row, based on `order` direction\n* If `total` then `count` number of `type` must be found within all notes", + "description": "How to test the notes for this Author\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order", "enum": [ "consecutive", "current", @@ -1849,7 +1827,7 @@ "type": "string" }, "type": { - "description": "User Note type key", + "description": "User Note type key to search for", "examples": [ "spamwarn" ], diff --git a/src/Schema/Rule.json b/src/Schema/Rule.json index c022880..5b7b7b9 100644 --- a/src/Schema/Rule.json +++ b/src/Schema/Rule.json @@ -85,12 +85,10 @@ "type": "string" }, "threshold": { - "default": "10%", - "description": "The number or percentage to trigger this rule at\n\n* If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger", - "type": [ - "string", - "number" - ] + "default": "> 10%", + "description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `[< OR > OR <= OR >=] [number][?percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution", + "pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$", + "type": "string" }, "thresholdOn": { "default": "all", @@ -868,9 +866,9 @@ "type": "string" }, "threshold": { - "default": 5, + "default": ">= 5", "description": "The number of repeat submissions that will trigger the rule", - "type": "number" + "type": "string" }, "useSubmissionAsReference": { "default": true, @@ -908,15 +906,6 @@ "description": "At least one count property must be present. If both are present then either can trigger the rule", "minProperties": 1, "properties": { - "count": { - "default": 1, - "description": "The number of activities in each subreddit from the list that will trigger this rule", - "examples": [ - 1 - ], - "minimum": 1, - "type": "number" - }, "subreddits": { "description": "A list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]", "examples": [ @@ -931,14 +920,14 @@ "minItems": 2, "type": "array" }, - "totalCount": { - "default": 1, - "description": "The total number of activities across all listed subreddits that will trigger this rule", + "threshold": { + "default": ">= 1", + "description": "A string containing a comparison operator and a value to compare recent activities against\n\nThe syntax is `[< OR > OR <= OR >=] [number][?percent sign]`\n\n* EX `> 3` => greater than 3 activities found in the listed subreddits\n* EX `<= 75%` => number of Activities in the subreddits listed are equal to or less than 75% of all Activities\n\n**Note:** If you use percentage comparison here as well as `useSubmissionAsReference` then \"all Activities\" is only pertains to Activities that had the Link of the Submission, rather than all Activities from this window.", "examples": [ - 1 + ">= 1" ], - "minimum": 1, - "type": "number" + "pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$", + "type": "string" } }, "required": [ @@ -992,28 +981,17 @@ "UserNoteCriteria": { "properties": { "count": { - "default": 1, - "description": "Number of occurrences of this type. Ignored if `search` is `current`", + "default": ">= 1", + "description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) [percent sign] [ascending|descending]`", "examples": [ 1 ], - "type": "number" - }, - "order": { - "default": "descending", - "description": "Time-based order to search Notes in for `consecutive` search", - "enum": [ - "ascending", - "descending" - ], - "examples": [ - "descending" - ], + "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?asc.*|desc.*)*$", "type": "string" }, "search": { "default": "current", - "description": "* If `current` then only the most recent note is checked\n* If `consecutive` then `count` number of `type` notes must be found in a row, based on `order` direction\n* If `total` then `count` number of `type` must be found within all notes", + "description": "How to test the notes for this Author\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order", "enum": [ "consecutive", "current", @@ -1025,7 +1003,7 @@ "type": "string" }, "type": { - "description": "User Note type key", + "description": "User Note type key to search for", "examples": [ "spamwarn" ], diff --git a/src/Schema/RuleSet.json b/src/Schema/RuleSet.json index 23c359c..65bd0e2 100644 --- a/src/Schema/RuleSet.json +++ b/src/Schema/RuleSet.json @@ -65,12 +65,10 @@ "type": "string" }, "threshold": { - "default": "10%", - "description": "The number or percentage to trigger this rule at\n\n* If `threshold` is a `number` then it is the absolute number of attribution instances to trigger at\n* If `threshold` is a `string` with percentage (EX `40%`) then it is the percentage of the total (see `lookAt`) this attribution must reach to trigger", - "type": [ - "string", - "number" - ] + "default": "> 10%", + "description": "A string containing a comparison operator and a value to compare comments against\n\nThe syntax is `[< OR > OR <= OR >=] [number][?percent sign]`\n\n* EX `> 12` => greater than 12 activities originate from same attribution\n* EX `<= 10%` => less than 10% of all Activities have the same attribution", + "pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$", + "type": "string" }, "thresholdOn": { "default": "all", @@ -848,9 +846,9 @@ "type": "string" }, "threshold": { - "default": 5, + "default": ">= 5", "description": "The number of repeat submissions that will trigger the rule", - "type": "number" + "type": "string" }, "useSubmissionAsReference": { "default": true, @@ -888,15 +886,6 @@ "description": "At least one count property must be present. If both are present then either can trigger the rule", "minProperties": 1, "properties": { - "count": { - "default": 1, - "description": "The number of activities in each subreddit from the list that will trigger this rule", - "examples": [ - 1 - ], - "minimum": 1, - "type": "number" - }, "subreddits": { "description": "A list of subreddits (case-insensitive) to look for. Do not include \"r/\" prefix.\n\nEX to match against /r/mealtimevideos and /r/askscience use [\"mealtimevideos\",\"askscience\"]", "examples": [ @@ -911,14 +900,14 @@ "minItems": 2, "type": "array" }, - "totalCount": { - "default": 1, - "description": "The total number of activities across all listed subreddits that will trigger this rule", + "threshold": { + "default": ">= 1", + "description": "A string containing a comparison operator and a value to compare recent activities against\n\nThe syntax is `[< OR > OR <= OR >=] [number][?percent sign]`\n\n* EX `> 3` => greater than 3 activities found in the listed subreddits\n* EX `<= 75%` => number of Activities in the subreddits listed are equal to or less than 75% of all Activities\n\n**Note:** If you use percentage comparison here as well as `useSubmissionAsReference` then \"all Activities\" is only pertains to Activities that had the Link of the Submission, rather than all Activities from this window.", "examples": [ - 1 + ">= 1" ], - "minimum": 1, - "type": "number" + "pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$", + "type": "string" } }, "required": [ @@ -972,28 +961,17 @@ "UserNoteCriteria": { "properties": { "count": { - "default": 1, - "description": "Number of occurrences of this type. Ignored if `search` is `current`", + "default": ">= 1", + "description": "Number of occurrences of this type. Ignored if `search` is `current`\n\nA string containing a comparison operator and/or a value to compare number of occurrences against\n\nThe syntax is `(< OR > OR <= OR >=) [percent sign] [ascending|descending]`", "examples": [ 1 ], - "type": "number" - }, - "order": { - "default": "descending", - "description": "Time-based order to search Notes in for `consecutive` search", - "enum": [ - "ascending", - "descending" - ], - "examples": [ - "descending" - ], + "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?asc.*|desc.*)*$", "type": "string" }, "search": { "default": "current", - "description": "* If `current` then only the most recent note is checked\n* If `consecutive` then `count` number of `type` notes must be found in a row, based on `order` direction\n* If `total` then `count` number of `type` must be found within all notes", + "description": "How to test the notes for this Author\n\n### current\n\nOnly the most recent note is checked for `type`\n\n### total\n\nThe `count` comparison of `type` must be found within all notes\n\n* EX `count: > 3` => Must have more than 3 notes of `type`, total\n* EX `count: <= 25%` => Must have 25% or less of notes of `type`, total\n\n### consecutive\n\nThe `count` **number** of `type` notes must be found in a row.\n\nYou may also specify the time-based order in which to search the notes by specifying `ascending (asc)` or `descending (desc)` in the `count` value. Default is `descending`\n\n* EX `count: >= 3` => Must have 3 or more notes of `type` consecutively, in descending order\n* EX `count: < 2` => Must have less than 2 notes of `type` consecutively, in descending order\n* EX `count: > 4 asc` => Must have greater than 4 notes of `type` consecutively, in ascending order", "enum": [ "consecutive", "current", @@ -1005,7 +983,7 @@ "type": "string" }, "type": { - "description": "User Note type key", + "description": "User Note type key to search for", "examples": [ "spamwarn" ], diff --git a/src/Utils/SnoowrapUtils.ts b/src/Utils/SnoowrapUtils.ts index 6c8bda0..b021f83 100644 --- a/src/Utils/SnoowrapUtils.ts +++ b/src/Utils/SnoowrapUtils.ts @@ -13,15 +13,16 @@ import { TypedActivityStates } from "../Common/interfaces"; import { - compareDurationValue, + compareDurationValue, comparisonTextOp, isActivityWindowCriteria, normalizeName, parseDuration, - parseDurationComparison, + parseDurationComparison, parseGenericValueOrPercentComparison, truncateStringToLength } from "../util"; import UserNotes from "../Subreddit/UserNotes"; import {Logger} from "winston"; import InvalidRegexError from "./InvalidRegexError"; +import SimpleError from "./SimpleError"; export const BOT_LINK = 'https://www.reddit.com/r/ContextModBot/comments/o1dugk/introduction_to_contextmodbot_and_rcb'; @@ -266,7 +267,8 @@ export const renderContent = async (template: string, data: (Submission | Commen export const testAuthorCriteria = async (item: (Comment | Submission), authorOpts: AuthorCriteria, include = true, userNotes: UserNotes) => { // @ts-ignore - const author: RedditUser = await item.author; + const author: RedditUser = await item.author.fetch(); + debugger; for (const k of Object.keys(authorOpts)) { // @ts-ignore if (authorOpts[k] !== undefined) { @@ -336,7 +338,9 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt const notes = await userNotes.getUserNotes(item.author); const notePass = () => { for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) { - const {count = 1, order = 'descending', search = 'current', type} = noteCriteria; + const {count = '>= 1', search = 'current', type} = noteCriteria; + const {value, operator, isPercent, extra = ''} = parseGenericValueOrPercentComparison(count); + const order = extra.includes('asc') ? 'ascending' : 'descending'; switch (search) { case 'current': if (notes.length > 0 && notes[notes.length - 1].noteType === type) { @@ -356,13 +360,20 @@ export const testAuthorCriteria = async (item: (Comment | Submission), authorOpt } else { currCount = 0; } - if (currCount >= count) { + if(isPercent) { + throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`); + } + if (comparisonTextOp(currCount, operator, value)) { return true; } } break; case 'total': - if (notes.filter(x => x.noteType === type).length >= count) { + if(isPercent) { + if(comparisonTextOp(notes.filter(x => x.noteType === type).length / notes.length, operator, value/100)) { + return true; + } + } else if(comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) { return true; } } diff --git a/src/util.ts b/src/util.ts index 4da3bb2..d80767e 100644 --- a/src/util.ts +++ b/src/util.ts @@ -207,21 +207,6 @@ export const createAjvFactory = (logger: Logger) => { return new Ajv({logger: logger, verbose: true, strict: "log", allowUnionTypes: true}); } -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`); - } -} - export const percentFromString = (str: string): number => { const n = Number.parseInt(str.replace('%', '')); if(Number.isNaN(n)) { @@ -385,9 +370,42 @@ export const parseFromJsonOrYamlToObject = (content: string): [object?, Error?, 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.number}` + } +} + const GENERIC_VALUE_PERCENT_COMPARISON = /^\s*(?>|>=|<|<=)\s*(?\d+)\s*(?%?)(?.*)$/ const GENERIC_VALUE_PERCENT_COMPARISON_URL = 'https://regexr.com/60a16'; -export const parseGenericComparison = (val: string): GenericComparison => { +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) @@ -396,8 +414,8 @@ export const parseGenericComparison = (val: string): GenericComparison => { return { operator: groups.opStr as StringOperator, - value: Number.parseFloat(groups.number), - isPercent: groups.percent !== undefined, + value: Number.parseFloat(groups.value), + isPercent: groups.percent !== '', extra: groups.extra, displayText: `${groups.opStr} ${groups.number}${groups.percent === undefined ? '': '%'}` }