From 3435c683c8507cadcd797c1e3c7eb311cb6fd2e9 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 5 Oct 2022 11:24:44 -0400 Subject: [PATCH 01/17] feat(filter): Add author flair item criteria --- .../Infrastructure/Filters/FilterCriteria.ts | 45 ++++ src/Schema/Action.json | 157 +++++++++++- src/Schema/App.json | 225 +++++++++++++++++- src/Schema/Check.json | 225 +++++++++++++++++- src/Schema/OperatorConfig.json | 157 +++++++++++- src/Schema/Rule.json | 225 +++++++++++++++++- src/Schema/RuleSet.json | 225 +++++++++++++++++- src/Schema/Run.json | 225 +++++++++++++++++- src/Subreddit/SubredditResources.ts | 77 ++++-- tests/itemCriteria.test.ts | 106 ++++++--- 10 files changed, 1608 insertions(+), 59 deletions(-) diff --git a/src/Common/Infrastructure/Filters/FilterCriteria.ts b/src/Common/Infrastructure/Filters/FilterCriteria.ts index aaa38671..21445d59 100644 --- a/src/Common/Infrastructure/Filters/FilterCriteria.ts +++ b/src/Common/Infrastructure/Filters/FilterCriteria.ts @@ -9,6 +9,7 @@ import { import {ActivityType} from "../Reddit"; import {GenericComparison, parseGenericValueComparison} from "../Comparisons"; import {parseStringToRegexOrLiteralSearch} from "../../../util"; +import { Submission, Comment } from "snoowrap"; /** * Different attributes a `Subreddit` can be in. Only include a property if you want to check it. @@ -485,6 +486,33 @@ export interface ActivityState { * * */ source?: string | string[] + + /** + * * If `true` then passes if ANY flair + * * If `false` then passes if NO flair + * * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes. + * */ + authorFlairText?: boolean | string | string[] + /** + * * If `true` then passes if ANY flair + * * If `false` then passes if NO flair + * * If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes. + * */ + authorFlairTemplateId?: boolean | string | string[] + + /** + * * If `true` then passes if ANY class + * * If `false` then passes if NO class + * * If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes. + * */ + authorFlairCssClass?: boolean | string | string[] + + /** + * * If `true` then passes if ANY color + * * If `false` then passes if NO color + * * If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes. + * */ + authorFlairBackgroundColor?: boolean | string | string[] } /** @@ -507,13 +535,22 @@ export interface SubmissionState extends ActivityState { /** * * If `true` then passes if flair has ANY text * * If `false` then passes if flair has NO text + * * If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes. * */ link_flair_text?: boolean | string | string[] /** * * If `true` then passes if flair has ANY css * * If `false` then passes if flair has NO css + * * If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes. * */ link_flair_css_class?: boolean | string | string[] + + /** + * * If `true` then passes if ANY color + * * If `false` then passes if NO color + * * If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes. + * */ + link_flair_background_color?: boolean | string | string[] /** * * If `true` then passes if there is ANY flair template id * * If `false` then passes if there is NO flair template id @@ -537,6 +574,14 @@ export interface SubmissionState extends ActivityState { upvoteRatio?: number | CompareValue } +export const cmToSnoowrapActivityMap: Record = { + authorFlairText: 'author_flair_text', + authorFlairTemplateId: 'author_flair_template_id', + authorFlairCssClass: 'author_flair_css_class', + authorFlairBackgroundColor: 'author_flair_background_color', + flairTemplate: 'link_flair_template_id' +} + export const cmActivityProperties = ['submissionState', 'score', 'reports', 'removed', 'deleted', 'filtered', 'age', 'title']; /** diff --git a/src/Schema/Action.json b/src/Schema/Action.json index ae131ed2..5940d49a 100644 --- a/src/Schema/Action.json +++ b/src/Schema/Action.json @@ -764,6 +764,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -2522,6 +2590,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -2586,6 +2722,23 @@ "is_self": { "type": "boolean" }, + "link_flair_background_color": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, "link_flair_css_class": { "anyOf": [ { @@ -2601,7 +2754,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css" + "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "link_flair_text": { "anyOf": [ @@ -2618,7 +2771,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text" + "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "locked": { "type": "boolean" diff --git a/src/Schema/App.json b/src/Schema/App.json index 1306e93b..cf6350d8 100644 --- a/src/Schema/App.json +++ b/src/Schema/App.json @@ -28,6 +28,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -1704,6 +1772,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -6252,6 +6388,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -6316,6 +6520,23 @@ "is_self": { "type": "boolean" }, + "link_flair_background_color": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, "link_flair_css_class": { "anyOf": [ { @@ -6331,7 +6552,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css" + "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "link_flair_text": { "anyOf": [ @@ -6348,7 +6569,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text" + "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "locked": { "type": "boolean" diff --git a/src/Schema/Check.json b/src/Schema/Check.json index 7ccf6cd3..db3fcaa4 100644 --- a/src/Schema/Check.json +++ b/src/Schema/Check.json @@ -42,6 +42,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -1527,6 +1595,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -5696,6 +5832,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -5760,6 +5964,23 @@ "is_self": { "type": "boolean" }, + "link_flair_background_color": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, "link_flair_css_class": { "anyOf": [ { @@ -5775,7 +5996,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css" + "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "link_flair_text": { "anyOf": [ @@ -5792,7 +6013,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text" + "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "locked": { "type": "boolean" diff --git a/src/Schema/OperatorConfig.json b/src/Schema/OperatorConfig.json index f5605f0f..4e8a1a10 100644 --- a/src/Schema/OperatorConfig.json +++ b/src/Schema/OperatorConfig.json @@ -534,6 +534,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -1860,6 +1928,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -1924,6 +2060,23 @@ "is_self": { "type": "boolean" }, + "link_flair_background_color": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, "link_flair_css_class": { "anyOf": [ { @@ -1939,7 +2092,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css" + "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "link_flair_text": { "anyOf": [ @@ -1956,7 +2109,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text" + "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "locked": { "type": "boolean" diff --git a/src/Schema/Rule.json b/src/Schema/Rule.json index 8db99ac1..b8c3ceb0 100644 --- a/src/Schema/Rule.json +++ b/src/Schema/Rule.json @@ -63,6 +63,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -781,6 +849,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -3476,6 +3612,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -3540,6 +3744,23 @@ "is_self": { "type": "boolean" }, + "link_flair_background_color": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, "link_flair_css_class": { "anyOf": [ { @@ -3555,7 +3776,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css" + "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "link_flair_text": { "anyOf": [ @@ -3572,7 +3793,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text" + "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "locked": { "type": "boolean" diff --git a/src/Schema/RuleSet.json b/src/Schema/RuleSet.json index 3044149c..e9ae4190 100644 --- a/src/Schema/RuleSet.json +++ b/src/Schema/RuleSet.json @@ -28,6 +28,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -746,6 +814,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -3441,6 +3577,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -3505,6 +3709,23 @@ "is_self": { "type": "boolean" }, + "link_flair_background_color": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, "link_flair_css_class": { "anyOf": [ { @@ -3520,7 +3741,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css" + "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "link_flair_text": { "anyOf": [ @@ -3537,7 +3758,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text" + "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "locked": { "type": "boolean" diff --git a/src/Schema/Run.json b/src/Schema/Run.json index a8790b83..bb141f7b 100644 --- a/src/Schema/Run.json +++ b/src/Schema/Run.json @@ -39,6 +39,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -1524,6 +1592,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -5893,6 +6029,74 @@ ], "description": "* true/false => test whether Activity is approved or not\n* string or list of strings => test which moderator approved this Activity" }, + "authorFlairBackgroundColor": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairCssClass": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY class\n* If `false` then passes if NO class\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairTemplateId": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then template id is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, + "authorFlairText": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY flair\n* If `false` then passes if NO flair\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." + }, "createdOn": { "anyOf": [ { @@ -5957,6 +6161,23 @@ "is_self": { "type": "boolean" }, + "link_flair_background_color": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": [ + "string", + "boolean" + ] + } + ], + "description": "* If `true` then passes if ANY color\n* If `false` then passes if NO color\n* If string or list of strings then color is matched, case-insensitive, without #. String may also be a regular expression enclosed in forward slashes." + }, "link_flair_css_class": { "anyOf": [ { @@ -5972,7 +6193,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css" + "description": "* If `true` then passes if flair has ANY css\n* If `false` then passes if flair has NO css\n* If string or list of strings then class is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "link_flair_text": { "anyOf": [ @@ -5989,7 +6210,7 @@ ] } ], - "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text" + "description": "* If `true` then passes if flair has ANY text\n* If `false` then passes if flair has NO text\n* If string or list of strings then text is matched, case-insensitive. String may also be a regular expression enclosed in forward slashes." }, "locked": { "type": "boolean" diff --git a/src/Subreddit/SubredditResources.ts b/src/Subreddit/SubredditResources.ts index 6350f13a..4aec1075 100644 --- a/src/Subreddit/SubredditResources.ts +++ b/src/Subreddit/SubredditResources.ts @@ -112,9 +112,20 @@ import cloneDeep from "lodash/cloneDeep"; import { asModLogCriteria, asModNoteCriteria, - AuthorCriteria, CommentState, ModLogCriteria, ModNoteCriteria, orderedAuthorCriteriaProps, RequiredAuthorCrit, - StrongSubredditCriteria, SubmissionState, - SubredditCriteria, toFullModLogCriteria, toFullModNoteCriteria, TypedActivityState, TypedActivityStates, + AuthorCriteria, + cmToSnoowrapActivityMap, + CommentState, + ModLogCriteria, + ModNoteCriteria, + orderedAuthorCriteriaProps, + RequiredAuthorCrit, + StrongSubredditCriteria, + SubmissionState, + SubredditCriteria, + toFullModLogCriteria, + toFullModNoteCriteria, + TypedActivityState, + TypedActivityStates, UserNoteCriteria } from "../Common/Infrastructure/Filters/FilterCriteria"; import { @@ -2654,13 +2665,22 @@ export class SubredditResources { case 'flairTemplate': case 'link_flair_text': case 'link_flair_css_class': - if(asSubmission(item)) { - let propertyValue: string | null; - if(k === 'flairTemplate') { - propertyValue = await item.link_flair_template_id; - } else { - propertyValue = await item[k]; - } + case 'link_flair_background_color': + case 'authorFlairText': + case 'authorFlairCssClass': + case 'authorFlairTemplateId': + case 'authorFlairBackgroundColor': + + let actualPropName = cmToSnoowrapActivityMap[k] ?? k; + + if(!asSubmission(item) && (actualPropName as string).includes('link_flair')) { + propResultsMap[k]!.passed = true; + propResultsMap[k]!.reason = `Cannot test for ${k} on Comment`; + log.warn(`Cannot test for ${k} on Comment`); + break; + } else { + // @ts-ignore + let propertyValue: string | null = await item[actualPropName]; propResultsMap[k]!.found = propertyValue; @@ -2674,15 +2694,38 @@ export class SubredditResources { // if crit is not a boolean but property is "empty" then it'll never pass anyway propResultsMap[k]!.passed = !include; } else { - const expectedValues = typeof itemOptVal === 'string' ? [itemOptVal] : (itemOptVal as string[]); - propResultsMap[k]!.passed = criteriaPassWithIncludeBehavior(expectedValues.some(x => x.trim().toLowerCase() === propertyValue?.trim().toLowerCase()), include); + // remove # if comparing hex values + const isHex = k.toLowerCase().includes('background'); + + const expectedValues = (typeof itemOptVal === 'string' ? [itemOptVal] : (itemOptVal as string[])).map(x => isHex ? x.replace('#','').trim() : x.trim()); + const cleanProp = isHex ? propertyValue.replace('#','').trim() : propertyValue.trim(); + let anyPassed = false; + const errorReasons = []; + for(const expectedVal of expectedValues) { + try { + const [regPassed] = testMaybeStringRegex(expectedVal,cleanProp); + if(regPassed) { + anyPassed = true; + } + } catch (err: any) { + if(err.message.includes('Could not convert test value')) { + errorReasons.push(`Could not convert ${expectedVal} to Regex, fallback to simple case-insenstive comparison`); + // fallback to simple comparison + anyPassed = expectedVal.toLowerCase() === cleanProp.toLowerCase(); + } else { + errorReasons.push(err.message); + } + } + if(anyPassed) { + break; + } + } + if(errorReasons.length > 0) { + propResultsMap[k]!.reason = `Some errors occurred while testing: ${errorReasons.join(' | ')}`; + } + propResultsMap[k]!.passed = criteriaPassWithIncludeBehavior(anyPassed, include); } break; - } else { - propResultsMap[k]!.passed = true; - propResultsMap[k]!.reason = `Cannot test for ${k} on Comment`; - log.warn(`Cannot test for ${k} on Comment`); - break; } default: diff --git a/tests/itemCriteria.test.ts b/tests/itemCriteria.test.ts index 1494941d..4d7851c2 100644 --- a/tests/itemCriteria.test.ts +++ b/tests/itemCriteria.test.ts @@ -16,6 +16,7 @@ import Snoowrap from "snoowrap"; import {getResource, getSnoowrap, getSubreddit, sampleActivity} from "./testFactory"; import {Subreddit as SubredditEntity} from "../src/Common/Entities/Subreddit"; import {Activity} from '../src/Common/Entities/Activity'; +import {cmToSnoowrapActivityMap} from "../src/Common/Infrastructure/Filters/FilterCriteria"; dayjs.extend(dduration); dayjs.extend(utc); @@ -229,50 +230,99 @@ describe('Item Criteria', function () { }, snoowrap, false), {upvoteRatio: '> 33'}, NoopLogger, true)).passed); }); - it('Should detect specific link flair template', async function () { - assert.isTrue((await resource.isItem(new Submission({ - link_flair_template_id: 'test', - }, snoowrap, false), {flairTemplate: 'test'}, NoopLogger, true)).passed); - assert.isTrue((await resource.isItem(new Submission({ - link_flair_template_id: 'test', - }, snoowrap, false), {flairTemplate: ['foo','test']}, NoopLogger, true)).passed); - assert.isFalse((await resource.isItem(new Submission({ - link_flair_template_id: 'test', - }, snoowrap, false), {flairTemplate: ['foo']}, NoopLogger, true)).passed); - }); - it('Should detect any link flair template', async function () { - assert.isTrue((await resource.isItem(new Submission({ - link_flair_template_id: 'test', - }, snoowrap, false), {flairTemplate: true}, NoopLogger, true)).passed); - }); - it('Should detect no link flair template', async function () { - assert.isTrue((await resource.isItem(new Submission({ - link_flair_template_id: null - }, snoowrap, false), {flairTemplate: false}, NoopLogger, true)).passed); - }); + for(const prop of ['link_flair_text', 'link_flair_css_class', 'authorFlairCssClass', 'authorFlairTemplateId', 'authorFlairText', 'flairTemplate']) { + const activityPropName = cmToSnoowrapActivityMap[prop] ?? prop; - for(const prop of ['link_flair_text', 'link_flair_css_class']) { - it(`Should detect specific ${prop}`, async function () { + it(`Should detect specific ${prop} as single string`, async function () { assert.isTrue((await resource.isItem(new Submission({ - [prop]: 'test', + [activityPropName]: 'test', }, snoowrap, false), {[prop]: 'test'}, NoopLogger, true)).passed); + }); + it(`Should detect specific ${prop} from array of string`, async function () { assert.isTrue((await resource.isItem(new Submission({ - [prop]: 'test', + [activityPropName]: 'test', }, snoowrap, false), {[prop]: ['foo','test']}, NoopLogger, true)).passed); + }); + it(`Should detect specific ${prop} is not in criteria`, async function () { assert.isFalse((await resource.isItem(new Submission({ - [prop]: 'test', + [activityPropName]: 'test', }, snoowrap, false), {[prop]: ['foo']}, NoopLogger, true)).passed); }); it(`Should detect any ${prop}`, async function () { assert.isTrue((await resource.isItem(new Submission({ - [prop]: 'test', + [activityPropName]: 'test', + }, snoowrap, false), {[prop]: true}, NoopLogger, true)).passed); + }); + it(`Should detect no ${prop}`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: null + }, snoowrap, false), {[prop]: false}, NoopLogger, true)).passed); + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: '' + }, snoowrap, false), {[prop]: false}, NoopLogger, true)).passed); + assert.isFalse((await resource.isItem(new Submission({ + [activityPropName]: '' + }, snoowrap, false), {[prop]: 'foo'}, NoopLogger, true)).passed); + }); + it(`Should detect ${prop} as Regular Expression`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: 'test' + }, snoowrap, false), {[prop]: '/te.*/'}, NoopLogger, true)).passed); + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: 'test' + }, snoowrap, false), {[prop]: ['foo', '/t.*/']}, NoopLogger, true)).passed); + }); + } + + for(const prop of ['authorFlairBackgroundColor', 'link_flair_background_color']) { + const activityPropName = cmToSnoowrapActivityMap[prop] ?? prop; + + it(`Should detect specific ${prop} as single string`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: '#400080', + }, snoowrap, false), {[prop]: '#400080'}, NoopLogger, true)).passed); + }); + it(`Should detect specific ${prop} from array of string`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: '#400080', + }, snoowrap, false), {[prop]: ['#903480','#400080']}, NoopLogger, true)).passed); + }); + it(`Should detect specific ${prop} is not in criteria`, async function () { + assert.isFalse((await resource.isItem(new Submission({ + [activityPropName]: '#400080', + }, snoowrap, false), {[prop]: ['#903480']}, NoopLogger, true)).passed); + }); + it(`Should detect any ${prop}`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: '#400080', }, snoowrap, false), {[prop]: true}, NoopLogger, true)).passed); }); it(`Should detect no ${prop}`, async function () { assert.isTrue((await resource.isItem(new Submission({ - [prop]: null + [activityPropName]: null }, snoowrap, false), {[prop]: false}, NoopLogger, true)).passed); }); + it(`Should detect ${prop} and remove # prefix`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: '#400080' + }, snoowrap, false), {[prop]: '400080'}, NoopLogger, true)).passed); + }); + it(`Should detect ${prop} as Regular Expression`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: '#400080' + }, snoowrap, false), {[prop]: '/#400.*/'}, NoopLogger, true)).passed); + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: '#400080' + }, snoowrap, false), {[prop]: ['#903480', '/400.*/']}, NoopLogger, true)).passed); + }); + } + + for(const prop of ['link_flair_text', 'link_flair_css_class', 'flairTemplate', 'link_flair_background_color']) { + it(`Should PASS submission criteria '${prop}' with a reason when Activity is a Comment`, async function () { + const result = await resource.isItem(new Comment({}, snoowrap, false), {[prop]: true}, NoopLogger, true); + assert.isTrue(result.passed); + assert.equal(result.propertyResults[0].reason, `Cannot test for ${prop} on Comment`) + }); } for(const prop of ['pinned', 'spoiler', 'is_self', 'over_18', 'locked', 'distinguished']) { From adc69894fc2a9cf9a1ba373043c6294e653c5c65 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 10 Oct 2022 11:03:57 -0400 Subject: [PATCH 02/17] fix(filter): Fix detecting empty filter when using 'replace' filter default behavior --- src/util.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/util.ts b/src/util.ts index 4761e083..70073e53 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2489,20 +2489,24 @@ export const mergeFilters = (objectConfig: RunnableBaseJson, filterDefs: FilterC let derivedAuthorIs: AuthorOptions = buildFilter(authorIsDefault); if (authorIsBehavior === 'merge') { derivedAuthorIs = merge.all([authorIs, authorIsDefault], {arrayMerge: removeFromSourceIfKeysExistsInDestination}); - } else if (Object.keys(authorIs).length > 0) { + } else if (!filterIsEmpty(authorIs)) { derivedAuthorIs = authorIs; } let derivedItemIs: ItemOptions = buildFilter(itemIsDefault); if (itemIsBehavior === 'merge') { derivedItemIs = merge.all([itemIs, itemIsDefault], {arrayMerge: removeFromSourceIfKeysExistsInDestination}); - } else if (Object.keys(itemIs).length > 0) { + } else if (!filterIsEmpty(itemIs)) { derivedItemIs = itemIs; } return [derivedAuthorIs, derivedItemIs]; } +export const filterIsEmpty = (obj: FilterOptions): boolean => { + return (obj.include === undefined || obj.include.length === 0) && (obj.exclude === undefined || obj.exclude.length === 0); +} + export const buildFilter = (filterVal: MinimalOrFullMaybeAnonymousFilter): FilterOptions => { if(Array.isArray(filterVal)) { const named = filterVal.map(x => normalizeCriteria(x)); From 1cf8855a24e47a783f7656817fda1b9ddb0faf60 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 10 Oct 2022 12:06:08 -0400 Subject: [PATCH 03/17] feat(testing): Implement initial author filter tests --- .../Infrastructure/Filters/FilterCriteria.ts | 4 +- tests/authorCriteria.test.ts | 193 ++++++++++++++++++ tests/testFactory.ts | 6 + 3 files changed, 202 insertions(+), 1 deletion(-) create mode 100644 tests/authorCriteria.test.ts diff --git a/src/Common/Infrastructure/Filters/FilterCriteria.ts b/src/Common/Infrastructure/Filters/FilterCriteria.ts index 21445d59..acf6a5e8 100644 --- a/src/Common/Infrastructure/Filters/FilterCriteria.ts +++ b/src/Common/Infrastructure/Filters/FilterCriteria.ts @@ -576,10 +576,12 @@ export interface SubmissionState extends ActivityState { export const cmToSnoowrapActivityMap: Record = { authorFlairText: 'author_flair_text', + flairText: 'author_flair_text', authorFlairTemplateId: 'author_flair_template_id', authorFlairCssClass: 'author_flair_css_class', authorFlairBackgroundColor: 'author_flair_background_color', - flairTemplate: 'link_flair_template_id' + flairTemplate: 'link_flair_template_id', + flairCssClass: 'author_flair_css_class', } export const cmActivityProperties = ['submissionState', 'score', 'reports', 'removed', 'deleted', 'filtered', 'age', 'title']; diff --git a/tests/authorCriteria.test.ts b/tests/authorCriteria.test.ts new file mode 100644 index 00000000..a49a6ab0 --- /dev/null +++ b/tests/authorCriteria.test.ts @@ -0,0 +1,193 @@ +import {describe, it} from 'mocha'; +import {assert} from 'chai'; +import dayjs from "dayjs"; +import dduration, {Duration, DurationUnitType} from 'dayjs/plugin/duration.js'; +import utc from 'dayjs/plugin/utc.js'; +import advancedFormat from 'dayjs/plugin/advancedFormat'; +import tz from 'dayjs/plugin/timezone'; +import relTime from 'dayjs/plugin/relativeTime.js'; +import sameafter from 'dayjs/plugin/isSameOrAfter.js'; +import samebefore from 'dayjs/plugin/isSameOrBefore.js'; +import weekOfYear from 'dayjs/plugin/weekOfYear.js'; +import {SubredditResources} from "../src/Subreddit/SubredditResources"; +import {NoopLogger} from '../src/Utils/loggerFactory'; +import {Subreddit, Comment, Submission, RedditUser} from 'snoowrap/dist/objects'; +import Snoowrap from "snoowrap"; +import {getResource, getSnoowrap, getSubreddit, sampleActivity} from "./testFactory"; +import {Subreddit as SubredditEntity} from "../src/Common/Entities/Subreddit"; +import {Activity} from '../src/Common/Entities/Activity'; +import {cmToSnoowrapActivityMap} from "../src/Common/Infrastructure/Filters/FilterCriteria"; +import {SnoowrapActivity} from "../src/Common/Infrastructure/Reddit"; + +dayjs.extend(dduration); +dayjs.extend(utc); +dayjs.extend(relTime); +dayjs.extend(sameafter); +dayjs.extend(samebefore); +dayjs.extend(tz); +dayjs.extend(advancedFormat); +dayjs.extend(weekOfYear); + + + +describe('Author Criteria', function () { + let resource: SubredditResources; + let snoowrap: Snoowrap; + let subreddit: Subreddit; + let subredditEntity: SubredditEntity; + + before(async () => { + resource = await getResource(); + snoowrap = await getSnoowrap(); + subreddit = await getSubreddit(); + subredditEntity = await resource.database.getRepository(SubredditEntity).save(new SubredditEntity({ + id: subreddit.id, + name: subreddit.name + })); + }); + + const testAuthor = (userProps: any = {}, activityType: string = 'submission', activityProps: any = {}) => { + const author = new RedditUser({ + name: 'aTestUser', + is_suspended: false, + ...userProps, + }, snoowrap, true); + + + let activity: SnoowrapActivity; + if (activityType === 'submission') { + activity = new Submission({ + created: 1664220502, + ...activityProps, + }, snoowrap, false); + } else { + activity = new Comment({ + created: 1664220502, + ...activityProps, + }, snoowrap, false); + } + + // @ts-ignore + author._fetch = author; + activity.author = author; + return activity; + }; + + describe('Moderator accessible criteria', function () { + + // TODO isContributor + }); + + describe('Publicly accessible criteria', function () { + + it('Should match name literal', async function () { + assert.isTrue((await resource.isAuthor(testAuthor(), {name: ['foo','test']}, true)).passed); + }); + + it('Should match name regex', async function () { + assert.isTrue((await resource.isAuthor(testAuthor(), {name: ['/fo.*/i','/te.*/i']}, true)).passed); + }); + + for(const prop of ['flairCssClass', 'flairTemplate', 'flairText']) { + let activityPropName = cmToSnoowrapActivityMap[prop] ?? prop; + if(activityPropName === 'link_flair_template_id') { + activityPropName = 'author_flair_template_id'; + } + + it(`Should detect specific ${prop} as single string`, async function () { + assert.isTrue((await resource.isAuthor(testAuthor({}, 'submission',{ + [activityPropName]: 'test', + }), {[prop]: 'test'}, true)).passed); + }); + it(`Should detect specific ${prop} from array of string`, async function () { + assert.isTrue((await resource.isAuthor(testAuthor({}, 'submission',{ + [activityPropName]: 'test', + }), {[prop]: ['foo','test']}, true)).passed); + }); + it(`Should detect specific ${prop} is not in criteria`, async function () { + assert.isFalse((await resource.isAuthor(testAuthor({}, 'submission',{ + [activityPropName]: 'test', + }), {[prop]: ['foo']}, true)).passed); + }); + it(`Should detect any ${prop}`, async function () { + assert.isTrue((await resource.isAuthor(testAuthor({}, 'submission',{ + [activityPropName]: 'test', + }), {[prop]: true}, true)).passed); + }); + it(`Should detect no ${prop}`, async function () { + assert.isTrue((await resource.isAuthor(testAuthor({}, 'submission',{ + [activityPropName]: null, + }), {[prop]: false}, true)).passed); + assert.isTrue((await resource.isAuthor(testAuthor({}, 'submission',{ + [activityPropName]: '', + }), {[prop]: false}, true)).passed); + assert.isFalse((await resource.isAuthor(testAuthor({}, 'submission',{ + [activityPropName]: '', + }), {[prop]: 'foo'}, true)).passed); + }); + /*it(`Should detect ${prop} as Regular Expression`, async function () { + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: 'test' + }, snoowrap, false), {[prop]: '/te.*!/'}, NoopLogger, true)).passed); + assert.isTrue((await resource.isItem(new Submission({ + [activityPropName]: 'test' + }, snoowrap, false), {[prop]: ['foo', '/t.*!/']}, NoopLogger, true)).passed); + });*/ + } + + // TODO isMod + // TODO shadowbanned + + it('Should detect age', async function () { + const time = dayjs().subtract(5, 'minutes').unix(); + const agedAuthor = testAuthor({created: time}) + assert.isTrue((await resource.isAuthor(agedAuthor, {age: '> 4 minutes'}, true)).passed); + assert.isTrue((await resource.isAuthor(agedAuthor, {age: '< 10 minutes'}, true)).passed); + }); + + it('Should match link karma', async function () { + const author = testAuthor({link_karma: 10}) + assert.isTrue((await resource.isAuthor(author, {linkKarma: '> 4'}, true)).passed); + assert.isTrue((await resource.isAuthor(author, {linkKarma: '< 11'}, true)).passed); + }); + + it('Should match comment karma', async function () { + const author = testAuthor({comment_karma: 10}) + assert.isTrue((await resource.isAuthor(author, {commentKarma: '> 4'}, true)).passed); + assert.isTrue((await resource.isAuthor(author, {commentKarma: '< 11'}, true)).passed); + }); + + it('Should match total karma', async function () { + const author = testAuthor({total_karma: 10}) + assert.isTrue((await resource.isAuthor(author, {totalKarma: '> 4'}, true)).passed); + assert.isTrue((await resource.isAuthor(author, {totalKarma: '< 11'}, true)).passed); + }); + + it('Should check verfied email status', async function () { + const author = testAuthor({has_verified_mail: true}) + assert.isTrue((await resource.isAuthor(author, {verified: true}, true)).passed); + }); + + it('Should match profile description literal', async function () { + const author = testAuthor({subreddit: new Subreddit({ + display_name: { + public_description: 'this is a test' + } + }, snoowrap, true)}); + assert.isTrue((await resource.isAuthor(author, {description: 'this is a test'}, true)).passed); + }); + + it('Should match profile description regex', async function () { + const author = testAuthor({subreddit: new Subreddit({ + display_name: { + public_description: 'this is a test' + } + }, snoowrap, true)}); + assert.isTrue((await resource.isAuthor(author, {description: '/te.*/i'}, true)).passed); + }); + + // TODO usernotes + // TODO modactions + }); +}); + diff --git a/tests/testFactory.ts b/tests/testFactory.ts index 5caaf168..99ed886d 100644 --- a/tests/testFactory.ts +++ b/tests/testFactory.ts @@ -79,6 +79,12 @@ export const getBot = async () => { bot = new Bot(config.bots[0], NoopLogger); await bot.cacheManager.set('test', { logger: NoopLogger, + caching: { + authorTTL: false, + submissionTTL: false, + commentTTL: false, + provider: 'memory' + }, subreddit: bot.client.getSubreddit('test'), client: bot.client, statFrequency: 'minute', From 74dfe9258a552f98c178e0a683a83853aea3d6bd Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 10 Oct 2022 15:00:39 -0400 Subject: [PATCH 04/17] fix(filter): Fix mod action note filtering assignment --- src/Subreddit/SubredditResources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Subreddit/SubredditResources.ts b/src/Subreddit/SubredditResources.ts index 4aec1075..410c19af 100644 --- a/src/Subreddit/SubredditResources.ts +++ b/src/Subreddit/SubredditResources.ts @@ -3187,7 +3187,7 @@ export class SubredditResources { let actionsToUse: ModNote[] = []; if(asModNoteCriteria(actionCriteria)) { - actionsToUse = actionsToUse.filter(x => x.type === 'NOTE'); + actionsToUse = modActions.filter(x => x.type === 'NOTE'); } else { actionsToUse = modActions; } From b174c7928aacf0da9619e270354b8509b0d8b207 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 11 Oct 2022 10:17:01 -0400 Subject: [PATCH 05/17] feat(ui): Add favicon files --- .../assets/public/android-chrome-192x192.png | Bin 0 -> 7313 bytes .../assets/public/android-chrome-512x512.png | Bin 0 -> 23868 bytes src/Web/assets/public/apple-touch-icon.png | Bin 0 -> 6684 bytes src/Web/assets/public/favicon-16x16.png | Bin 0 -> 620 bytes src/Web/assets/public/favicon-32x32.png | Bin 0 -> 1123 bytes src/Web/assets/public/favicon.ico | Bin 0 -> 15406 bytes src/Web/assets/public/site.webmanifest | 1 + src/Web/assets/views/partials/head.ejs | 5 +++++ 8 files changed, 6 insertions(+) create mode 100644 src/Web/assets/public/android-chrome-192x192.png create mode 100644 src/Web/assets/public/android-chrome-512x512.png create mode 100644 src/Web/assets/public/apple-touch-icon.png create mode 100644 src/Web/assets/public/favicon-16x16.png create mode 100644 src/Web/assets/public/favicon-32x32.png create mode 100644 src/Web/assets/public/favicon.ico create mode 100644 src/Web/assets/public/site.webmanifest diff --git a/src/Web/assets/public/android-chrome-192x192.png b/src/Web/assets/public/android-chrome-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..f6fcd024e8ed199b40411524947713fc431ec63d GIT binary patch literal 7313 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 literal 0 HcmV?d00001 diff --git a/src/Web/assets/public/favicon-16x16.png b/src/Web/assets/public/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..61b1293b2cb1bc83a03cea3dad74d4306b4a12fd GIT binary patch literal 620 zcmV-y0+aoTP)L9ysU5n9R-bREo45CtSza3Qqp?dB^JzT^NJ(+wdKmv|H|_V4`Wrm==89onA%=0xwOd0-IN*D} z9Wf#qnR!}TU04NaJq1MlvWE1wj5C(Etw(YfrvvToR%A{~{2u0Ps|yiFO8|x3**fmL z4)r3qA%bfHeV{#knM}$`)2alJ8|%Tm-Doo!BdmktNa|e`0N|?F;1lz(!1|;+^j&XP z#57)#4$l5{;ZrKUF{;c$H2_aC!WJoPVbG5es27%eEAf7=xVpsB(rN>sTZB&t)u0Y^ zpDcW&E&$j+;*t{o0Sx9rWIX(H#W@x?zg4)=|WI^);*0EUt00004#l`tR1i}M>#20j3Wf3MUI=6Nr47S@f-8x8nf5-Fd^>+2LcDK!#iG6AA z@1EZ|pWiv(bAFe>fBa*W#vg74x7VY7z&INBjl_FxQOH!{t73q7o9|WR%2^o{g?KqM zn7FW_0OMO%H2|;qkuflg^uw2$Zp=-tD1ZzW3#%UivTQIieWa4$a!=VMnBJz=ri9o< zfnVgDbs#dq0M831e)c9^m!2G%N|giS7+_+HXT5NH-Ur6sqyU~pgCL}V;nytl;CSF# z`Y$ISjsW6q^_|Gs8A@2K6Q_unWWv}Vj3&Qb%6>_JnC5#MfS;z5FKPR)V2A<%4}~I$ z&q_g*bQ%lScTla*QgHr{lN2C!QR6iM<2`xDbYyaLg8u%b^6dN<>cH6W<7BuEjCHy;fK~@AV&b;@pu93gE89v5i+@-fqt*ZOW*ouFKasq2Jr| zI~cUqP|A7r9ewIygZhNDQ8FUcI5ab3x4*v`)+B3HpIo1a=y!JP02rNH@Y|f$Cs|CZ zBSpJLt<6FhI{}ruT!EXV064;48iC)L8pNSsM1R30ystXGV4tU0yX*)*E>{ zA=;M*KnBPd+XL5Tez2VX(Y9u@!m56?TDT~{_;YoQP_5je)^W?tk7fX`0>br2YL_)W z$0}E3VIBLa6<@cW$F=%TX?z^Cp6viMq~!O*_FNmrHMJWV%e9FefZFSDU_d`*2N(-M z4PWao5EW*#1~xZG6Y;UhNwzeUErhya^};C6|*D6_jDh;Vg|=7 zd30G_kXupA&o~bF)y9Fu$wf!;*j9f_IRNC3oMHzBGa+)3=m*e8;_kEn0?}l?-SgV8 zi%UM23&0S2DOiBgp6>z#afJgrXK6ud;IRS#Wcv_YKUo(PN8C*})H5pv5QymAv9SL# zC6Fd5UZ=^pg}ULL6$PLx{8>_0J57_pz(8`aupKKd0iEVJjjS*1T9FqJ4V#47IoK;R zV7FNwfW=fQ+o<3&`u_n;Zu36%0IQD`IlS5CLY!foAILojxD*`J`|b8I&A*H4^O{_* pGBQWkMN?CDoAN%jOA%H0?r-DqTasOlp2h$G002ovPDHLkV1np&4^aRB literal 0 HcmV?d00001 diff --git a/src/Web/assets/public/favicon.ico b/src/Web/assets/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..5fa3cd91c5d57d119de936cb295d9881b83c1b61 GIT binary patch literal 15406 zcmeHOd2C$88J{@eaO~Z8n5zYfxtho%_O9Q13#BSe1?2{nOB_;B5JVhRTaHEuM2X7L z2Esg}JD^j@u*-rSZMeS25N z6{Ldy{uMIr4CYq;l}vkwBn|iA$~odw(m2zOFbjdkr-}4yIehKjXxD|M0`==cx#64{ zRX_OcF-+5H4DJy{6P4Fy*we|sN(J4_p3ddab6b=g`1O@c6Q{i!Nk8iFC(ZnH|8T&# zr*hp!v;KO#sTKz5Y-Uq$UN z%}JOSo@JfTzm3zRp91UAGTvPFYxwOSm=5#)thoA_7xUu=(Xp--ZPCwp9FuOe$6@@f zXT00%uiu?dGUaw>eWR_PY^@o6KO+4!HsN~{>!!Gvex=hn5VTvEHp2dIp#0u&NTywX zUC4Z>^An_-e^?X$oy(&2J6mbpteEaWGF@1|TqdXG`G@k=w09KirkK{{!2B)qgzHcF zw1=pNe)!u5vTjaeJoMM)q^p>ItT`vkbBj2nXE)?ck@KR`Vn45$^QpaAXCR)oKEX6X zET1BN9+WFOhIS3m^9S{zj61(x0eg)3;s~#q#a9xe?fSUwYK<`_t*Jz(Oasi?e8^B_j6emWG`YKr2V}gm7{(G zoR2>Bq|D=&b*yaV@;EW=siCLPhv3iugSoIl6msTb|k zZs!NEwF?aW)$XHf!Gf{oRQ3bSuurT-R#m(mD|f|u>vk1w5OwZWmCT(?V{QRDC&3Y| zn2j*e&Pf2den0p#_31c)_8MZ^>V(WAAg3HCI_nGYh@lbZ*5zb`%P}4wiScMKPQY5` zYK$Y-$UJJgKd_Dt>!EMZFOqo@M*rbabaC>q?*9dkXO+g7mv6!t6kLno%&VMUtYQ2! zSk|Lv+@F=>DObJ|f9|{xYm|ntKm1~an(FT=6=uYy6FgnFG_P4vlXA zWQV5%|8kWka3bf;T+&C^o5V*{5bfrU%CZ%6EAU~eKYxFc>Pc< ze!0&QMqutmp2v9cjXPuaYyPH~@%!&8By4KNc{F@(hq}Ka^WrSv@_OQzWs|*eA8`)O zfav@=vQ1?CIAi@~fIs7UbdE;&_&1jL;Y;t(xof^Zua~jOX|{hO;fKAqsYdpE-)`9B zz-L~UG0W+O#BaJQG-J@e51~3J-R^7|vz%@;{N(F+TXHkq1F+xHz7qb_Bje_DMfh=F zteW0EkiC`pC@$82wT$}-?&!`f71UI=E4=xxSKW6w- z_6cF^xo_P6Sk7H&pTN4Pk?_Mt??q$JKG5` zeozj0-maf_JO1M`6zz z){Sv*6Z+oYwC?^nc=Pf~cpM~p%0s_ft{C2hpuL-T!gBZ*O zW6yJy%!{$Jm&-UtomR;-*cV;FWpYgIg7F;h9(nAMxoSvbTtoXmf;x=YZ$M`s){zFi zc~J=8!s%v=uIEW%v6tHU@G`QLzAk;Uf6J`nA33i{^)V0#nxT|<2t zKDHI{YUKYL|Bv_wy;G{kx+XThSPVl=+F=9x=W%CqcINeLEJ5Q(jQ;?k4L(90oJ#I~ z#}8N;nJCIe%%lHn>6ZN(_gA#T@|GEEtNKsNc+@YqXK>$? z@V}bt1LJ|F8l6K;I5WP8)(Qx#ah5%MBKc3Q#;60{K+`*5A?7v47++0O?Bv;Lfd;m~ EcdnA)A^-pY literal 0 HcmV?d00001 diff --git a/src/Web/assets/public/site.webmanifest b/src/Web/assets/public/site.webmanifest new file mode 100644 index 00000000..45dc8a20 --- /dev/null +++ b/src/Web/assets/public/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/src/Web/assets/views/partials/head.ejs b/src/Web/assets/views/partials/head.ejs index a7983084..dab1ac5b 100644 --- a/src/Web/assets/views/partials/head.ejs +++ b/src/Web/assets/views/partials/head.ejs @@ -13,4 +13,9 @@ + + + + + From 9b12d0b2b3478d44b1209bb67024202c96d5e400 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 11 Oct 2022 10:51:00 -0400 Subject: [PATCH 06/17] feat(bot): Improve log wording for manager loading phase --- src/Bot/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Bot/index.ts b/src/Bot/index.ts index dd644036..57ceb373 100644 --- a/src/Bot/index.ts +++ b/src/Bot/index.ts @@ -529,7 +529,7 @@ class Bot implements BotInstanceFunctions { for (const sub of subsToRun) { if(!this.subManagers.some(x => x.subreddit.display_name === sub.display_name)) { subManagersChanged = true; - this.logger.info(`Manager for ${sub.display_name_prefixed} not found in existing managers. Creating now...`); + this.logger.info(`Manager for ${sub.display_name_prefixed} not found in loaded managers. Loading now...`); subsToInit.push(sub.display_name); try { this.subManagers.push(await this.createManager(sub)); @@ -743,6 +743,9 @@ class Bot implements BotInstanceFunctions { eventsState: new EventsRunState({invokee, runType}), managerState: new ManagerRunState({invokee, runType}) })); + this.logger.info(`Created new Manager (${managerEntity.id}) for ${subVal.display_name}`); + } else { + this.logger.info(`Found existing Manager (${managerEntity.id}) for ${subVal.display_name}`); } const manager = new Manager(sub, this.client, this.logger, this.cacheManager, { From 6ee060c5ce02ff108312aa70660ea88c1ce36595 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 11 Oct 2022 12:18:00 -0400 Subject: [PATCH 07/17] fix(logs): Remove listeners from log stream event emitter before end of response to prevent write-after-end errors --- src/Web/Server/routes/authenticated/user/logs.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Web/Server/routes/authenticated/user/logs.ts b/src/Web/Server/routes/authenticated/user/logs.ts index 484b63bb..9fb73387 100644 --- a/src/Web/Server/routes/authenticated/user/logs.ts +++ b/src/Web/Server/routes/authenticated/user/logs.ts @@ -73,8 +73,10 @@ const logs = () => { const requestedBots = bots.map(x => x.botName); const origin = req.header('X-Forwarded-For') ?? req.header('host'); + const stream = logger.stream(); try { - logger.stream().on('log', (log: LogInfo) => { + + stream.on('log', (log: LogInfo) => { if (isLogLineMinLevel(log, level as string)) { const {subreddit: subName, bot, user} = log; let canAccess = false; @@ -105,13 +107,13 @@ const logs = () => { logger.info(`${userName} from ${origin} => CONNECTED`); await pEvent(req, 'close'); //logger.debug('Request closed detected with "close" listener'); - res.destroy(); return; } catch (e: any) { if (e.code !== 'ECONNRESET') { logger.error(e); } } finally { + stream.removeAllListeners(); logger.info(`${userName} from ${origin} => DISCONNECTED`); res.destroy(); } From 8b125d74339f883b8791c08c6e9fc8664faf88da Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 11 Oct 2022 12:19:55 -0400 Subject: [PATCH 08/17] feat(ui): Improve visibility and resilience for live log stream * Tie loading indicator to live stream status and display error if one occurs * Add manual restart action to end of error * Restart stream automatically if reader ends, up to 3 retries --- src/Web/assets/public/app.css | 10 +- src/Web/assets/views/partials/loadingIcon.ejs | 2 +- src/Web/assets/views/status.ejs | 127 +++++++++++++----- 3 files changed, 106 insertions(+), 33 deletions(-) diff --git a/src/Web/assets/public/app.css b/src/Web/assets/public/app.css index f6559829..7fbd4e9b 100644 --- a/src/Web/assets/public/app.css +++ b/src/Web/assets/public/app.css @@ -169,6 +169,14 @@ a { display: inherit; } +.show { + display: inherit; +} + +.invisible { + display: none; +} + .triggeredStateToggle { cursor: pointer; } @@ -183,7 +191,7 @@ li > ul { } .smallLi:before { - margin-left: -10px; + margin-left: -5px; content: "" } diff --git a/src/Web/assets/views/partials/loadingIcon.ejs b/src/Web/assets/views/partials/loadingIcon.ejs index ba1d6813..44660113 100644 --- a/src/Web/assets/views/partials/loadingIcon.ejs +++ b/src/Web/assets/views/partials/loadingIcon.ejs @@ -1,4 +1,4 @@ - <%- include('partials/logSettings') %> +
<%- include('partials/loadingIcon') %> + +
<% data.logs.forEach(function (logEntry){ %> <%- logEntry %> @@ -751,6 +757,17 @@ }); }) + document.querySelectorAll('.restartLogs').forEach(el => { + el.addEventListener('click', e => { + e.preventDefault(); + const subSection = e.target.closest('div.sub'); + + if (subSection !== null) { + getStreamingLogs(subSection.dataset.subreddit, subSection.dataset.bot); + } + }); + }); + document.querySelectorAll(".checkUrl").forEach(el => { const toggleButtons = (e) => { const subFilter = `.sub[data-subreddit="${e.target.dataset.subreddit}"]`; @@ -962,7 +979,7 @@ }).observe(element); } - function getStreamingLogs(sub, bot) { + function getStreamingLogs(sub, bot, restarts = 0) { console.debug(`Getting stream for ${bot} ${sub}`); @@ -1018,38 +1035,49 @@ bufferedLogs = []; } + setLiveLogIndicator(bot, sub, true); const fetchPromise = fetch(`/api/logs?instance=<%= instanceId %>&bot=${bot}&subreddit=${sub}&level=${level}&sort=${sort}&limit=${limitSel}&stream=true&streamObjects=true&formatted=false`, {signal}) - .then(response => response.body) - .then(rs => - rs.pipeThrough(new TextDecoderStream()) - .pipeThrough(new TransformStream({ - transform(chunk, controller) { - textBuffer += chunk; - const lines = textBuffer.split('\n'); - for (const line of lines.slice(0, -1)) { - controller.enqueue(line); - } - textBuffer = lines.slice(-1)[0]; - }, - flush(controller) { - if (textBuffer) { - controller.enqueue(textBuffer); + .then(response => { + return response.body; + }) + .then(rs => { + return rs.pipeThrough(new TextDecoderStream()) + .pipeThrough(new TransformStream({ + transform(chunk, controller) { + textBuffer += chunk; + const lines = textBuffer.split('\n'); + for (const line of lines.slice(0, -1)) { + controller.enqueue(line); + } + textBuffer = lines.slice(-1)[0]; + }, + flush(controller) { + if (textBuffer) { + controller.enqueue(textBuffer); + } } - } - })) - - // Parse JSON objects - .pipeThrough(new TransformStream({ - transform(line, controller) { - if (line) { - controller.enqueue( - JSON.parse(line) - ); + })) + + // Parse JSON objects + .pipeThrough(new TransformStream({ + transform(line, controller) { + if (line) { + controller.enqueue( + JSON.parse(line) + ); + } } - } - })) - ).catch((e) => { - console.warn(e); + })); + } + ) + .catch((e) => { + if(e.name === 'AbortError') { + setLiveLogIndicator(bot, sub, false); + console.debug(`Log streaming for ${bot} ${sub} aborted`); + } else { + setLiveLogIndicator(bot, sub, false, `Live Log encountered an error: ${e.message}`); + console.warn(e); + } }); fetchPromise.then(async res => { @@ -1064,6 +1092,11 @@ if(done) { keepReading = false; console.debug(`${bot}.${sub} log stream reader signalled it is done`); + if(restarts < 3) { + getStreamingLogs(sub, bot, restarts + 1); + } else { + setLiveLogIndicator(bot, sub, false, `Tried to automatically restart stream too many times (${restarts +1}) which indicates something may be wrong with communication.`); + } } if(value) { //console.log(`((Logged For ${bot} ${sub})) ${value.message}`); @@ -1098,9 +1131,11 @@ }).catch((e) => { if(e.name !== 'AbortError') { console.debug(`Non-abort error occurred while streaming logs for ${bot} ${sub}`); - console.error(e); + console.warn(e); + setLiveLogIndicator(bot, sub, false, `Live Log encountered an error: ${e.message}`); } else { console.debug(`Log streaming for ${bot} ${sub} aborted`); + setLiveLogIndicator(bot, sub, false); } }); @@ -1108,6 +1143,36 @@ recentlySeen.set(`${bot}.${sub}`, {...existing, fetch: fetchPromise, controller, streamStart: Date.now()}); } + function setLiveLogIndicator(bot, sub, live, error = undefined) { + const liveIndicator = document.querySelector(`[data-bot="${bot}"][data-subreddit="${sub}"] .liveLogIndicator .loading`); + if(null !== liveIndicator) { + if(live) { + if(liveIndicator.classList.contains('invisible')) { + liveIndicator.classList.remove('invisible'); + } + // if(!liveIndicator.classList.contains('show')) { + // liveIndicator.classList.add('show'); + // } + } else { + if(!liveIndicator.classList.contains('invisible')) { + liveIndicator.classList.add('invisible'); + } + } + } + const liveErrorWrapper = document.querySelector(`[data-bot="${bot}"][data-subreddit="${sub}"] .liveLogIndicator .liveLogErrorWrapper`); + if(null !== liveErrorWrapper) { + if(live && !liveErrorWrapper.classList.contains('invisible')) { + liveErrorWrapper.classList.add('invisible'); + } + if(!live && error !== undefined) { + if(liveErrorWrapper.classList.contains('invisible')) { + liveErrorWrapper.classList.remove('invisible'); + } + document.querySelector(`[data-bot="${bot}"][data-subreddit="${sub}"] .liveLogIndicator .liveLogError`).innerHTML = error; + } + } + } + const delayedItemsMap = new Map(); let lastSeenIdentifier = null; const subIndicators = ['red', 'green', 'yellow']; From e98364eae92bf06900aa4d64bde23b30bf23759a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Oct 2022 12:58:49 -0400 Subject: [PATCH 09/17] feat(filter): Improve modAction filtering functionality * Implement filtering activityType by "false" in order to return actions/notes not added to a specific activity * Implement "referencesCurrentActivity" property to allow filtering by actions/notes that are associated with the activity being processed * Implement using "count" for "current" search to enable criteria condition based on presence or non-presence of current action/note passing --- .../Infrastructure/Filters/FilterCriteria.ts | 9 +- src/Common/Infrastructure/Reddit.ts | 1 + src/Subreddit/SubredditResources.ts | 294 +++++++++--------- src/util.ts | 46 +-- 4 files changed, 185 insertions(+), 165 deletions(-) diff --git a/src/Common/Infrastructure/Filters/FilterCriteria.ts b/src/Common/Infrastructure/Filters/FilterCriteria.ts index acf6a5e8..48dfcc0e 100644 --- a/src/Common/Infrastructure/Filters/FilterCriteria.ts +++ b/src/Common/Infrastructure/Filters/FilterCriteria.ts @@ -6,7 +6,7 @@ import { ModeratorNames, ModActionType, ModUserNoteLabel, RelativeDateTimeMatch } from "../Atomic"; -import {ActivityType} from "../Reddit"; +import {ActivityType, MaybeActivityType} from "../Reddit"; import {GenericComparison, parseGenericValueComparison} from "../Comparisons"; import {parseStringToRegexOrLiteralSearch} from "../../../util"; import { Submission, Comment } from "snoowrap"; @@ -123,13 +123,14 @@ export interface UserNoteCriteria extends UserSubredditHistoryCriteria { export interface ModActionCriteria extends UserSubredditHistoryCriteria { type?: ModActionType | ModActionType[] - activityType?: ActivityType | ActivityType[] + activityType?: MaybeActivityType | MaybeActivityType[] + referencesCurrentActivity?: boolean } export interface FullModActionCriteria extends Omit { type?: ModActionType[] count?: GenericComparison - activityType?: ActivityType[] + activityType?: MaybeActivityType[] } export interface ModNoteCriteria extends ModActionCriteria { @@ -168,6 +169,7 @@ export const toFullModNoteCriteria = (val: ModNoteCriteria): FullModNoteCriteria break; case 'activityType': case 'noteType': + case 'referencesCurrentActivity': acc[k] = rawVal; break; case 'note': @@ -220,6 +222,7 @@ export const toFullModLogCriteria = (val: ModLogCriteria): FullModLogCriteria => break; case 'activityType': case 'type': + case 'referencesCurrentActivity': acc[k as keyof FullModLogCriteria] = rawVal; break; case 'action': diff --git a/src/Common/Infrastructure/Reddit.ts b/src/Common/Infrastructure/Reddit.ts index ac77e570..0135b3dd 100644 --- a/src/Common/Infrastructure/Reddit.ts +++ b/src/Common/Infrastructure/Reddit.ts @@ -1,6 +1,7 @@ import {Comment, Submission} from "snoowrap/dist/objects"; export type ActivityType = 'submission' | 'comment'; +export type MaybeActivityType = ActivityType | false; export type FullNameTypes = ActivityType | 'user' | 'subreddit' | 'message'; export interface RedditThing { diff --git a/src/Subreddit/SubredditResources.ts b/src/Subreddit/SubredditResources.ts index 410c19af..c472206e 100644 --- a/src/Subreddit/SubredditResources.ts +++ b/src/Subreddit/SubredditResources.ts @@ -155,7 +155,7 @@ import { ActivityType, AuthorHistorySort, - CachedFetchedActivitiesResult, FetchedActivitiesResult, + CachedFetchedActivitiesResult, FetchedActivitiesResult, MaybeActivityType, SnoowrapActivity, SubredditRemovalReason } from "../Common/Infrastructure/Reddit"; import {AuthorCritPropHelper} from "../Common/Infrastructure/Filters/AuthorCritPropHelper"; @@ -1232,6 +1232,156 @@ export class SubredditResources { return false; } + filterAuthorModActions(modActions: ModNote[], actionCriteria: (ModNoteCriteria | ModLogCriteria), referenceItem: SnoowrapActivity) { + const {search = 'current', count = '>= 1'} = actionCriteria; + + const { + value, + operator, + isPercent, + duration, + extra = '' + } = parseGenericValueOrPercentComparison(count); + + const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration); + + let actionsToUse: ModNote[] = []; + if(asModNoteCriteria(actionCriteria)) { + actionsToUse = modActions.filter(x => x.type === 'NOTE'); + } else { + actionsToUse = modActions; + } + + if(search === 'current' && actionsToUse.length > 0) { + actionsToUse = [actionsToUse[0]]; + } + + let validActions: ModNote[] = []; + if (asModLogCriteria(actionCriteria)) { + const fullCrit = toFullModLogCriteria(actionCriteria); + const fullCritEntries = Object.entries(fullCrit); + validActions = actionsToUse.filter(x => { + + // filter out any notes that occur before time range + if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) { + return false; + } + + for (const [k, v] of fullCritEntries) { + const key = k.toLocaleLowerCase(); + if (['count', 'search'].includes(key)) { + continue; + } + switch (key) { + case 'type': + if (!v.includes((x.type as ModActionType))) { + return false + } + break; + case 'activitytype': + const anyMatch = v.some((a: MaybeActivityType) => { + switch (a) { + case 'submission': + return isSubmission(x.action.actedOn); + case 'comment': + return isComment(x.action.actedOn); + case false: + return x.action.actedOn === undefined || (!asSubmission(x.action.actedOn) && !asComment(x.action.actedOn)); + } + }); + if (!anyMatch) { + return false; + } + break; + case 'description': + case 'action': + case 'details': + const actionPropVal = x.action[key] as string; + if (actionPropVal === undefined) { + return false; + } + const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal)); + if (!anyPropMatch) { + return false; + } + break; + case 'referencescurrentactivity': + const isCurrentActivity = x.action.actedOn !== undefined && referenceItem !== undefined && x.action.actedOn.name === referenceItem.name; + if((v === true && !isCurrentActivity) || (v === false && isCurrentActivity)) { + return false; + } + break; + } // case end + + } // for each end + + return true; + }); // filter end + } else if(asModNoteCriteria(actionCriteria)) { + const fullCrit = toFullModNoteCriteria(actionCriteria as ModNoteCriteria); + const fullCritEntries = Object.entries(fullCrit); + validActions = actionsToUse.filter(x => { + + // filter out any notes that occur before time range + if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) { + return false; + } + + for (const [k, v] of fullCritEntries) { + const key = k.toLocaleLowerCase(); + if (['count', 'search'].includes(key)) { + continue; + } + switch (key) { + case 'notetype': + if (!v.map((x: ModUserNoteLabel) => x.toUpperCase()).includes((x.note.label as ModUserNoteLabel))) { + return false + } + break; + case 'note': + const actionPropVal = x.note.note; + if (actionPropVal === undefined) { + return false; + } + const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal)); + if (!anyPropMatch) { + return false; + } + break; + case 'activitytype': + const anyMatch = v.some((a: MaybeActivityType) => { + switch (a) { + case 'submission': + return isSubmission(x.action.actedOn); + case 'comment': + return isComment(x.action.actedOn); + case false: + return x.action.actedOn === undefined || (!asSubmission(x.action.actedOn) && !asComment(x.action.actedOn)); + } + }); + if (!anyMatch) { + return false; + } + break; + case 'referencescurrentactivity': + const isCurrentActivity = x.action.actedOn !== undefined && referenceItem !== undefined && x.action.actedOn.id === referenceItem.name; + if((v === true && !isCurrentActivity) || (v === false && isCurrentActivity)) { + return false; + } + break; + } // case end + + } // for each end + + return true; + }); // filter end + } else { + throw new SimpleError(`Could not determine if a modActions criteria was for Mod Log or Mod Note. Given: ${JSON.stringify(actionCriteria)}`); + } + + return [validActions, actionsToUse]; + } + async getAuthorModNotesByActivityAuthor(activity: Comment | Submission) { const author = activity.author instanceof RedditUser ? activity.author : getActivityAuthorName(activity.author); if (activity.subreddit.display_name !== this.subreddit.display_name) { @@ -3175,7 +3325,6 @@ export class SubredditResources { const {search = 'current', count = '>= 1'} = actionCriteria; - const { value, operator, @@ -3183,146 +3332,10 @@ export class SubredditResources { duration, extra = '' } = parseGenericValueOrPercentComparison(count); - const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration); - let actionsToUse: ModNote[] = []; - if(asModNoteCriteria(actionCriteria)) { - actionsToUse = modActions.filter(x => x.type === 'NOTE'); - } else { - actionsToUse = modActions; - } - - if(search === 'current' && actionsToUse.length > 0) { - actionsToUse = [actionsToUse[0]]; - } - - let validActions: ModNote[] = []; - if (asModLogCriteria(actionCriteria)) { - const fullCrit = toFullModLogCriteria(actionCriteria); - const fullCritEntries = Object.entries(fullCrit); - validActions = actionsToUse.filter(x => { - - // filter out any notes that occur before time range - if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) { - return false; - } - - for (const [k, v] of fullCritEntries) { - const key = k.toLocaleLowerCase(); - if (['count', 'search'].includes(key)) { - continue; - } - switch (key) { - case 'type': - if (!v.includes((x.type as ModActionType))) { - return false - } - break; - case 'activitytype': - const anyMatch = v.some((a: ActivityType) => { - switch (a) { - case 'submission': - if (x.action.actedOn instanceof Submission) { - return true; - } - break; - case 'comment': - if (x.action.actedOn instanceof Comment) { - return true; - } - break; - } - }); - if (!anyMatch) { - return false; - } - break; - case 'description': - case 'action': - case 'details': - const actionPropVal = x.action[key] as string; - if (actionPropVal === undefined) { - return false; - } - const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal)); - if (!anyPropMatch) { - return false; - } - } // case end - - } // for each end - - return true; - }); // filter end - } else if(asModNoteCriteria(actionCriteria)) { - const fullCrit = toFullModNoteCriteria(actionCriteria as ModNoteCriteria); - const fullCritEntries = Object.entries(fullCrit); - validActions = actionsToUse.filter(x => { - - // filter out any notes that occur before time range - if(cutoffDate !== undefined && x.createdAt.isBefore(cutoffDate)) { - return false; - } - - for (const [k, v] of fullCritEntries) { - const key = k.toLocaleLowerCase(); - if (['count', 'search'].includes(key)) { - continue; - } - switch (key) { - case 'notetype': - if (!v.map((x: ModUserNoteLabel) => x.toUpperCase()).includes((x.note.label as ModUserNoteLabel))) { - return false - } - break; - case 'note': - const actionPropVal = x.note.note; - if (actionPropVal === undefined) { - return false; - } - const anyPropMatch = v.some((y: RegExp) => y.test(actionPropVal)); - if (!anyPropMatch) { - return false; - } - break; - case 'activitytype': - const anyMatch = v.some((a: ActivityType) => { - switch (a) { - case 'submission': - if (x.action.actedOn instanceof Submission) { - return true; - } - break; - case 'comment': - if (x.action.actedOn instanceof Comment) { - return true; - } - break; - } - }); - if (!anyMatch) { - return false; - } - break; - } // case end - - } // for each end - - return true; - }); // filter end - } else { - throw new SimpleError(`Could not determine if a modActions criteria was for Mod Log or Mod Note. Given: ${JSON.stringify(actionCriteria)}`); - } + const [validActions, actionsToUse] = this.filterAuthorModActions(modActions, actionCriteria, item); switch (search) { - case 'current': - if (validActions.length === 0) { - actionResult.push('No Mod Actions present'); - } else { - actionResult.push('Current Action matches criteria'); - return true; - } - break; case 'consecutive': if (isPercent) { throw new SimpleError(`When comparing Mod Actions with 'search: consecutive' the 'count' value cannot be a percentage. Given: ${count}`); @@ -3349,10 +3362,11 @@ export class SubredditResources { return true; } break; + case 'current': case 'total': if (isPercent) { // avoid divide by zero - const percent = notes.length === 0 ? 0 : validActions.length / actionsToUse.length; + const percent = actionsToUse.length === 0 ? 0 : validActions.length / actionsToUse.length; actionResult.push(`${formatNumber(percent)}% of ${actionsToUse.length} matched criteria`); if (comparisonTextOp(percent, operator, value / 100)) { return true; diff --git a/src/util.ts b/src/util.ts index 70073e53..53e3816d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -2610,28 +2610,7 @@ export const normalizeCriteria = { - const common = { - ...x, - type: x.type === undefined ? undefined : (Array.isArray(x.type) ? x.type : [x.type]) - } - if(asModNoteCriteria(x)) { - return { - ...common, - noteType: x.noteType === undefined ? undefined : (Array.isArray(x.noteType) ? x.noteType : [x.noteType]), - note: x.note === undefined ? undefined : (Array.isArray(x.note) ? x.note : [x.note]), - } - } else if(asModLogCriteria(x)) { - return { - ...common, - action: x.action === undefined ? undefined : (Array.isArray(x.action) ? x.action : [x.action]), - details: x.details === undefined ? undefined : (Array.isArray(x.details) ? x.details : [x.details]), - description: x.description === undefined ? undefined : (Array.isArray(x.description) ? x.description : [x.description]), - activityType: x.activityType === undefined ? undefined : (Array.isArray(x.activityType) ? x.activityType : [x.activityType]), - } - } - return common; - }) + criteria.modActions.map((x, index) => normalizeModActionCriteria(x)); } } @@ -2641,6 +2620,29 @@ export const normalizeCriteria = { + const common = { + ...x, + type: x.type === undefined ? undefined : (Array.isArray(x.type) ? x.type : [x.type]) + } + if(asModNoteCriteria(x)) { + return { + ...common, + noteType: x.noteType === undefined ? undefined : (Array.isArray(x.noteType) ? x.noteType : [x.noteType]), + note: x.note === undefined ? undefined : (Array.isArray(x.note) ? x.note : [x.note]), + } + } else if(asModLogCriteria(x)) { + return { + ...common, + action: x.action === undefined ? undefined : (Array.isArray(x.action) ? x.action : [x.action]), + details: x.details === undefined ? undefined : (Array.isArray(x.details) ? x.details : [x.details]), + description: x.description === undefined ? undefined : (Array.isArray(x.description) ? x.description : [x.description]), + activityType: x.activityType === undefined ? undefined : (Array.isArray(x.activityType) ? x.activityType : [x.activityType]), + } + } + return common; +} + export const asNamedCriteria = (val: MaybeAnonymousCriteria | undefined): val is NamedCriteria => { if(val === undefined || typeof val === 'string') { return false; From f527a17fa21191ff4d89b95917dd060af6eb94a5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Oct 2022 13:00:47 -0400 Subject: [PATCH 10/17] feat(modnote): Implement existing note check before adding note to replace allowDuplicates * Use "modActions" authorIs filtering to check note prior to adding note using "existingNoteCheck" property * Refactors concept of "allowDuplicates" to allow any arbitrary modActions test to be used * Provide convenience ModLogCriteria generation for "existingNoteCheck" based on boolean (emulates allowDuplicates functionality) --- src/Action/ModNoteAction.ts | 92 ++++++++++++++++++++++------------ src/Schema/Action.json | 45 +++++++++++------ src/Schema/App.json | 45 +++++++++++------ src/Schema/Check.json | 45 +++++++++++------ src/Schema/OperatorConfig.json | 22 +++++--- src/Schema/Rule.json | 22 +++++--- src/Schema/RuleSet.json | 22 +++++--- src/Schema/Run.json | 45 +++++++++++------ 8 files changed, 217 insertions(+), 121 deletions(-) diff --git a/src/Action/ModNoteAction.ts b/src/Action/ModNoteAction.ts index e0275dc4..31ad0e52 100644 --- a/src/Action/ModNoteAction.ts +++ b/src/Action/ModNoteAction.ts @@ -1,30 +1,32 @@ import {ActionJson, ActionConfig, ActionOptions} from "./index"; import Action from "./index"; import {Comment} from "snoowrap"; -import {renderContent} from "../Utils/SnoowrapUtils"; import Submission from "snoowrap/dist/objects/Submission"; import {ActionProcessResult, RichContent} from "../Common/interfaces"; -import {toModNoteLabel} from "../util"; +import {buildFilterCriteriaSummary, normalizeModActionCriteria, toModNoteLabel} from "../util"; import {RuleResultEntity} from "../Common/Entities/RuleResultEntity"; import {runCheckOptions} from "../Subreddit/Manager"; -import {ActionTypes, ModUserNoteLabel} from "../Common/Infrastructure/Atomic"; -import {ModNote} from "../Subreddit/ModNotes/ModNote"; +import { + ActionTypes, + ModUserNoteLabel, +} from "../Common/Infrastructure/Atomic"; import {ActionResultEntity} from "../Common/Entities/ActionResultEntity"; +import {ModNoteCriteria} from "../Common/Infrastructure/Filters/FilterCriteria"; export class ModNoteAction extends Action { content: string; type?: string; - allowDuplicate: boolean; + existingNoteCheck?: ModNoteCriteria referenceActivity: boolean constructor(options: ModNoteActionOptions) { super(options); - const {type, content = '', allowDuplicate = false, referenceActivity = true} = options; + const {type, content = '', existingNoteCheck = true, referenceActivity = true} = options; this.type = type; this.content = content; - this.allowDuplicate = allowDuplicate; this.referenceActivity = referenceActivity; + this.existingNoteCheck = typeof existingNoteCheck === 'boolean' ? this.generateModLogCriteriaFromDuplicateConvenience(existingNoteCheck) : normalizeModActionCriteria(existingNoteCheck); } getKind(): ActionTypes { @@ -35,7 +37,7 @@ export class ModNoteAction extends Action { return { content: this.content, type: this.type, - allowDuplicate: this.allowDuplicate, + existingNoteCheck: this.existingNoteCheck, referenceActivity: this.referenceActivity, } } @@ -48,27 +50,30 @@ export class ModNoteAction extends Action { const renderedContent = await this.renderContent(this.content, item, ruleResults, actionResults); this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`); - // TODO see what changes are made for bulk fetch of notes before implementing this - // https://www.reddit.com/r/redditdev/comments/t8w861/new_mod_notes_api/ - // if (!this.allowDuplicate) { - // const notes = await this.resources.userNotes.getUserNotes(item.author); - // let existingNote = notes.find((x) => x.link !== null && x.link.includes(item.id)); - // if(existingNote === undefined && notes.length > 0) { - // const lastNote = notes[notes.length - 1]; - // // possibly notes don't have a reference link so check if last one has same text - // if(lastNote.link === null && lastNote.text === renderedContent) { - // existingNote = lastNote; - // } - // } - // if (existingNote !== undefined && existingNote.noteType === this.type) { - // this.logger.info(`Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`); - // return { - // dryRun, - // success: false, - // result: `Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false` - // }; - // } - // } + let noteCheckPassed: boolean = true; + let noteCheckResult: undefined | string; + + if(this.existingNoteCheck === undefined) { + // nothing to do! + noteCheckResult = 'existingNoteCheck=false so no existing note checks were performed.'; + } else { + const noteCheckCriteriaResult = await this.resources.isAuthor(item, { + modActions: [this.existingNoteCheck] + }); + noteCheckPassed = noteCheckCriteriaResult.passed; + const {details} = buildFilterCriteriaSummary(noteCheckCriteriaResult); + noteCheckResult = `${noteCheckPassed ? 'Existing note check condition succeeded' : 'Will not add note because existing note check condition failed'} -- ${details.join(' ')}`; + } + + this.logger.info(noteCheckResult); + if (!noteCheckPassed) { + return { + dryRun, + success: false, + result: noteCheckResult + }; + } + if (!dryRun) { await this.resources.addModNote({ label: modLabel, @@ -84,15 +89,36 @@ export class ModNoteAction extends Action { result: `${modLabel !== undefined ? `(${modLabel})` : ''} ${renderedContent}` } } + + generateModLogCriteriaFromDuplicateConvenience(val: boolean): ModNoteCriteria | undefined { + if(val) { + return { + noteType: this.type !== undefined ? [toModNoteLabel(this.type)] : undefined, + note: this.content !== '' ? [this.content] : undefined, + referencesCurrentActivity: this.referenceActivity ? true : undefined, + search: 'current', + count: '< 1' + } + } + return undefined; + } } export interface ModNoteActionConfig extends ActionConfig, RichContent { /** - * Add Note even if a Note already exists for this Activity - * @examples [false] - * @default false + * Check if there is an existing Note matching some criteria before adding the Note. + * + * If this check passes then the Note is added. The value may be a boolean or ModNoteCriteria. + * + * Boolean convenience: + * + * * If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria + * * If `false` then no check is performed and Note is always added + * + * @examples [true] + * @default true * */ - allowDuplicate?: boolean, + existingNoteCheck?: boolean | ModNoteCriteria, type?: ModUserNoteLabel referenceActivity?: boolean } diff --git a/src/Schema/Action.json b/src/Schema/Action.json index 5940d49a..be0ff715 100644 --- a/src/Schema/Action.json +++ b/src/Schema/Action.json @@ -1785,18 +1785,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -1835,6 +1835,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -1881,14 +1884,6 @@ "ModNoteActionJson": { "description": "Add a Toolbox User Note to the Author of this Activity", "properties": { - "allowDuplicate": { - "default": false, - "description": "Add Note even if a Note already exists for this Activity", - "examples": [ - false - ], - "type": "boolean" - }, "authorIs": { "anyOf": [ { @@ -1939,6 +1934,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/ModNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -2011,18 +2021,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -2081,6 +2091,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/App.json b/src/Schema/App.json index cf6350d8..c7a54ea7 100644 --- a/src/Schema/App.json +++ b/src/Schema/App.json @@ -3744,18 +3744,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -3794,6 +3794,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -3840,14 +3843,6 @@ "ModNoteActionJson": { "description": "Add a Toolbox User Note to the Author of this Activity", "properties": { - "allowDuplicate": { - "default": false, - "description": "Add Note even if a Note already exists for this Activity", - "examples": [ - false - ], - "type": "boolean" - }, "authorIs": { "anyOf": [ { @@ -3898,6 +3893,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/ModNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -3970,18 +3980,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -4040,6 +4050,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/Check.json b/src/Schema/Check.json index db3fcaa4..65a7500b 100644 --- a/src/Schema/Check.json +++ b/src/Schema/Check.json @@ -3458,18 +3458,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -3508,6 +3508,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -3554,14 +3557,6 @@ "ModNoteActionJson": { "description": "Add a Toolbox User Note to the Author of this Activity", "properties": { - "allowDuplicate": { - "default": false, - "description": "Add Note even if a Note already exists for this Activity", - "examples": [ - false - ], - "type": "boolean" - }, "authorIs": { "anyOf": [ { @@ -3612,6 +3607,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/ModNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -3684,18 +3694,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -3754,6 +3764,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/OperatorConfig.json b/src/Schema/OperatorConfig.json index 4e8a1a10..c0a47279 100644 --- a/src/Schema/OperatorConfig.json +++ b/src/Schema/OperatorConfig.json @@ -1133,18 +1133,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -1183,6 +1183,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -1234,18 +1237,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -1304,6 +1307,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/Rule.json b/src/Schema/Rule.json index b8c3ceb0..7bb32854 100644 --- a/src/Schema/Rule.json +++ b/src/Schema/Rule.json @@ -2085,18 +2085,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -2135,6 +2135,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -2186,18 +2189,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -2256,6 +2259,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/RuleSet.json b/src/Schema/RuleSet.json index e9ae4190..c70f4c57 100644 --- a/src/Schema/RuleSet.json +++ b/src/Schema/RuleSet.json @@ -2050,18 +2050,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -2100,6 +2100,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -2151,18 +2154,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -2221,6 +2224,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/Run.json b/src/Schema/Run.json index bb141f7b..4db39a9a 100644 --- a/src/Schema/Run.json +++ b/src/Schema/Run.json @@ -3525,18 +3525,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -3575,6 +3575,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -3621,14 +3624,6 @@ "ModNoteActionJson": { "description": "Add a Toolbox User Note to the Author of this Activity", "properties": { - "allowDuplicate": { - "default": false, - "description": "Add Note even if a Note already exists for this Activity", - "examples": [ - false - ], - "type": "boolean" - }, "authorIs": { "anyOf": [ { @@ -3679,6 +3674,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/ModNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or ModNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a ModNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -3751,18 +3761,18 @@ "items": { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] }, "type": "array" }, { "enum": [ "comment", + false, "submission" - ], - "type": "string" + ] } ] }, @@ -3821,6 +3831,9 @@ } ] }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", From a3ca3f17ec1ecda1c6f1cf8de0086246bc312b28 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 12 Oct 2022 14:39:21 -0400 Subject: [PATCH 11/17] refactor(recent): Log image parsing error with cause --- src/Rule/RecentActivityRule.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Rule/RecentActivityRule.ts b/src/Rule/RecentActivityRule.ts index 9a3cd6ac..fb34ca7c 100644 --- a/src/Rule/RecentActivityRule.ts +++ b/src/Rule/RecentActivityRule.ts @@ -44,6 +44,7 @@ import {ActivityWindow, ActivityWindowConfig} from "../Common/Infrastructure/Act import {comparisonTextOp, parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons"; import {ImageHashCacheData} from "../Common/Infrastructure/Atomic"; import {getSubredditBreakdownByActivityType} from "../Utils/SnoowrapUtils"; +import {CMError} from "../Utils/Errors"; const parseLink = parseUsableLinkIdentifier(); @@ -315,7 +316,7 @@ export class RecentActivityRule extends Rule { } } catch (err: any) { if(!err.message.includes('did not end with a valid image extension')) { - this.logger.warn(`Will not compare image from Submission ${x.id} due to error while parsing image URL => ${err.message}`); + this.logger.warn(new CMError(`Will not compare image from Submission ${x.id} due to error while parsing image URL`, {cause: err})); } } } From 2241d40e49fe9deea0339afaaed5a010ef57c95d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Oct 2022 08:58:12 -0400 Subject: [PATCH 12/17] fix: Fix custom footer never loading Custom footer needs to be loaded AFTER resources are set --- src/Subreddit/Manager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Subreddit/Manager.ts b/src/Subreddit/Manager.ts index 3db023e1..7a35815c 100644 --- a/src/Subreddit/Manager.ts +++ b/src/Subreddit/Manager.ts @@ -654,10 +654,6 @@ export class Manager extends EventEmitter implements RunningStates { this.displayLabel = nickname || `${this.subreddit.display_name_prefixed}`; - if (footer !== undefined && this.resources !== undefined) { - this.resources.footer = footer; - } - this.subMaxWorkers = maxWorkers; const realMax = this.getMaxWorkers(this.subMaxWorkers); if(realMax !== this.queue.concurrency) { @@ -697,6 +693,10 @@ export class Manager extends EventEmitter implements RunningStates { await this.setResourceManager(resourceConfig); this.resources.setLogger(this.logger); + if (footer !== undefined && this.resources !== undefined) { + this.resources.footer = footer; + } + this.logger.info('Subreddit-specific options updated'); this.logger.info('Building Runs and Checks...'); From 7fb69ae67aa691706edd5fef7f61210158c1a595 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Oct 2022 11:39:45 -0400 Subject: [PATCH 13/17] fix: Attempt to decrease frequency of influxdb timeout errors * Use keep-alive http agent to reuse open connections * Decrease max batch size and flush interval to payload sent is smaller * Add debug logging for fail/success/retry events on flush --- package-lock.json | 28 +++++------ package.json | 4 +- src/Common/Influx/InfluxClient.ts | 80 +++++++++++++++++++++++++++---- src/Common/Influx/interfaces.ts | 5 +- 4 files changed, 90 insertions(+), 27 deletions(-) diff --git a/package-lock.json b/package-lock.json index 624af07d..bf71b0fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,8 +13,8 @@ "@awaitjs/express": "^0.8.0", "@datasert/cronjs-matcher": "^1.2.0", "@googleapis/youtube": "^2.0.0", - "@influxdata/influxdb-client": "^1.27.0", - "@influxdata/influxdb-client-apis": "^1.27.0", + "@influxdata/influxdb-client": "^1.31.0", + "@influxdata/influxdb-client-apis": "^1.31.0", "@nlpjs/core": "^4.23.4", "@nlpjs/lang-de": "^4.23.4", "@nlpjs/lang-en": "^4.23.4", @@ -683,14 +683,14 @@ } }, "node_modules/@influxdata/influxdb-client": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.27.0.tgz", - "integrity": "sha512-hOBi+ApIurDd8jFWo+eYjMWWsDRp3wih/U/NOVRoHaTOE8ihSQthi9wfMD4YeVqt4pCN6ygIwo7lEKFXwNuwcA==" + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz", + "integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA==" }, "node_modules/@influxdata/influxdb-client-apis": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.27.0.tgz", - "integrity": "sha512-a4gd7CwNRXSsSVt9tm8GzGxuPXngEmQucMdoTZ0YYeWSbKUXz3B/3u9/EqMGEbtq5MdbbB2OKA611hu205UiNg==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz", + "integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==", "peerDependencies": { "@influxdata/influxdb-client": "*" } @@ -10858,14 +10858,14 @@ } }, "@influxdata/influxdb-client": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.27.0.tgz", - "integrity": "sha512-hOBi+ApIurDd8jFWo+eYjMWWsDRp3wih/U/NOVRoHaTOE8ihSQthi9wfMD4YeVqt4pCN6ygIwo7lEKFXwNuwcA==" + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client/-/influxdb-client-1.31.0.tgz", + "integrity": "sha512-8DVT3ZB/VeCK5Nn+BxhgMrAMSTseQAEgV20AK+ZMO5Fcup9XWsA9L2zE+3eBFl0Y+lF3UeKiASkiKMQvws35GA==" }, "@influxdata/influxdb-client-apis": { - "version": "1.27.0", - "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.27.0.tgz", - "integrity": "sha512-a4gd7CwNRXSsSVt9tm8GzGxuPXngEmQucMdoTZ0YYeWSbKUXz3B/3u9/EqMGEbtq5MdbbB2OKA611hu205UiNg==", + "version": "1.31.0", + "resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.31.0.tgz", + "integrity": "sha512-6ALGNLxtfffhICobOdj13Z6vj6gdQVOzVXPoPNd+w7V60zrbGhTqzXHV1KMZ/lzOb6YkRTRODbxz4W/b/7N5hg==", "requires": {} }, "@istanbuljs/load-nyc-config": { diff --git a/package.json b/package.json index 02bde9bf..021285ec 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "@awaitjs/express": "^0.8.0", "@datasert/cronjs-matcher": "^1.2.0", "@googleapis/youtube": "^2.0.0", - "@influxdata/influxdb-client": "^1.27.0", - "@influxdata/influxdb-client-apis": "^1.27.0", + "@influxdata/influxdb-client": "^1.31.0", + "@influxdata/influxdb-client-apis": "^1.31.0", "@nlpjs/core": "^4.23.4", "@nlpjs/lang-de": "^4.23.4", "@nlpjs/lang-en": "^4.23.4", diff --git a/src/Common/Influx/InfluxClient.ts b/src/Common/Influx/InfluxClient.ts index 67d7bd4e..1f47d2d0 100644 --- a/src/Common/Influx/InfluxClient.ts +++ b/src/Common/Influx/InfluxClient.ts @@ -1,10 +1,12 @@ import {InfluxConfig} from "./interfaces"; -import {InfluxDB, Point, WriteApi, setLogger} from "@influxdata/influxdb-client"; +import {InfluxDB, Point, WriteApi, setLogger, DEFAULT_WriteOptions, ClientOptions, DEFAULT_RetryDelayStrategyOptions} from "@influxdata/influxdb-client"; import {HealthAPI} from "@influxdata/influxdb-client-apis"; import dayjs, {Dayjs} from "dayjs"; import {Logger} from "winston"; import {mergeArr} from "../../util"; import {CMError} from "../../Utils/Errors"; +import {Agent} from 'http'; +import {WriteOptions} from "@influxdata/influxdb-client/dist"; export interface InfluxClientConfig extends InfluxConfig { client?: InfluxDB @@ -34,13 +36,14 @@ export class InfluxClient { this.config = rest; this.ready = ready; - if(client !== undefined) { + if (client !== undefined) { this.client = client; } else { - this.client = InfluxClient.createClient(this.config); - setLogger(this.logger); + this.client = InfluxClient.createClient(this.config); + setLogger(this.logger); } - this.write = this.client.getWriteApi(config.credentials.org, config.credentials.bucket, 'ms'); + + this.write = this.client.getWriteApi(config.credentials.org, config.credentials.bucket, 'ms', InfluxClient.createWriteOptions(this.config, this.logger)); this.tags = tags; this.write.useDefaultTags(tags); this.health = new HealthAPI(this.client); @@ -96,13 +99,70 @@ export class InfluxClient { } static createClient(config: InfluxConfig): InfluxDB { - return new InfluxDB({ - url: config.credentials.url, - token: config.credentials.token, + const { + credentials, + useKeepAliveAgent = true, + } = config; + + const clientOptions: ClientOptions = { + url: credentials.url, + token: credentials.token, + writeOptions: InfluxClient.createWriteOptions(config), + } + if (useKeepAliveAgent) { + // reusing connection + // https://github.com/influxdata/influxdb-client-js/issues/393#issuecomment-985272866 + const agent = new Agent({ + keepAlive: true, + keepAliveMsecs: 20 * 1000, // 20 seconds keep alive + }) + process.on('exit', () => agent.destroy()) + clientOptions.transportOptions = {agent}; + } + return new InfluxDB(clientOptions); + } + + static createWriteOptions(config: InfluxConfig, logger?: Logger): Partial { + const { writeOptions: { - defaultTags: config.defaultTags + defaultTags: userDefinedDefaultTags = {}, + ...restUserWriteOptions + } = { + batchSize: 500, + maxRetries: 5, + // 30 seconds + flushInterval: 30000 + }, + defaultTags: legacyDefaultTags = {}, + debug = false, + } = config; + + const allUserDefinedTags = {...legacyDefaultTags, ...userDefinedDefaultTags}; + + const writeOptions: Partial = { + ...DEFAULT_WriteOptions, + ...restUserWriteOptions, + defaultTags: allUserDefinedTags + } + + if (debug && logger !== undefined) { + writeOptions.writeFailed = (error: Error, lines: Array, attempt: number, expires: number): Promise | void => { + logger.debug(`Write failed for ${lines.length} lines after ${attempt}`); + if(error.message.includes('Request timed out')) { + logger.debug(`Influx Error: ${error.message}`); + } else { + logger.debug(error); + } + }; + writeOptions.writeSuccess = (lines: Array) => { + logger.debug(`Flushed ${lines.length} lines to server`); + }; + writeOptions.writeRetrySkipped = (entry: { lines: Array; expires: number }) => { + logger.debug(`Skipped ${entry.lines.length} lines due to full buffer?`); } - }); + } + + return writeOptions; } childClient(logger: Logger, tags: Record = {}) { diff --git a/src/Common/Influx/interfaces.ts b/src/Common/Influx/interfaces.ts index 8a8b72b1..74be9936 100644 --- a/src/Common/Influx/interfaces.ts +++ b/src/Common/Influx/interfaces.ts @@ -1,8 +1,11 @@ -import {InfluxDB, WriteApi} from "@influxdata/influxdb-client/dist"; +import {InfluxDB, WriteApi, WriteOptions} from "@influxdata/influxdb-client/dist"; export interface InfluxConfig { credentials: InfluxCredentials defaultTags?: Record + writeOptions?: WriteOptions + useKeepAliveAgent?: boolean + debug?: boolean } export interface InfluxCredentials { From 457f94760340af1379ca25e3fbbb8f8142484fe4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Oct 2022 12:34:11 -0400 Subject: [PATCH 14/17] fix: Further improvements for influxdb logging * Suppress write failure warnings to reduce noise in log * Can be toggled using debug flag in config --- src/Common/Influx/InfluxClient.ts | 36 +++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Common/Influx/InfluxClient.ts b/src/Common/Influx/InfluxClient.ts index 1f47d2d0..9f620637 100644 --- a/src/Common/Influx/InfluxClient.ts +++ b/src/Common/Influx/InfluxClient.ts @@ -1,5 +1,5 @@ import {InfluxConfig} from "./interfaces"; -import {InfluxDB, Point, WriteApi, setLogger, DEFAULT_WriteOptions, ClientOptions, DEFAULT_RetryDelayStrategyOptions} from "@influxdata/influxdb-client"; +import {InfluxDB, Point, WriteApi, setLogger, DEFAULT_WriteOptions, ClientOptions, DEFAULT_RetryDelayStrategyOptions, Logger as InfluxLogger} from "@influxdata/influxdb-client"; import {HealthAPI} from "@influxdata/influxdb-client-apis"; import dayjs, {Dayjs} from "dayjs"; import {Logger} from "winston"; @@ -13,6 +13,28 @@ export interface InfluxClientConfig extends InfluxConfig { ready?: boolean } +/** + * Suppress non-error write failures + * + * These have not yet hit the max retry. On max retry failure Influx logs as ERROR. + * The non-error failures are super noisy in the log so suppress them UNLESS debug is turned on + * + * https://github.com/influxdata/influxdb-client-js/blob/master/packages/core/src/impl/WriteApiImpl.ts#L221 + * */ +const extendLogger = (logger: Logger, suppressWriteWarnings = true): InfluxLogger => { + return { + ...logger, + error: (message: string, err?: any) => logger.error(message, err), + warn: (message: string, err?: any) => { + if(suppressWriteWarnings && !message.includes('Write to InfluxDB failed (attempt')) { + logger.warn(message, err); + } else { + logger.warn(message, err); + } + } + } +} + export class InfluxClient { config: InfluxConfig; client: InfluxDB; @@ -40,7 +62,7 @@ export class InfluxClient { this.client = client; } else { this.client = InfluxClient.createClient(this.config); - setLogger(this.logger); + setLogger(extendLogger(this.logger, !(rest.debug ?? false))); } this.write = this.client.getWriteApi(config.credentials.org, config.credentials.bucket, 'ms', InfluxClient.createWriteOptions(this.config, this.logger)); @@ -146,19 +168,11 @@ export class InfluxClient { } if (debug && logger !== undefined) { - writeOptions.writeFailed = (error: Error, lines: Array, attempt: number, expires: number): Promise | void => { - logger.debug(`Write failed for ${lines.length} lines after ${attempt}`); - if(error.message.includes('Request timed out')) { - logger.debug(`Influx Error: ${error.message}`); - } else { - logger.debug(error); - } - }; writeOptions.writeSuccess = (lines: Array) => { logger.debug(`Flushed ${lines.length} lines to server`); }; writeOptions.writeRetrySkipped = (entry: { lines: Array; expires: number }) => { - logger.debug(`Skipped ${entry.lines.length} lines due to full buffer?`); + logger.warn(`Skipped flushing ${entry.lines.length} lines due to full buffer`); } } From 98a8568eb69f55e7cd9c64c93e9dcebbf92fca6c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 13 Oct 2022 16:17:43 -0400 Subject: [PATCH 15/17] fix: Add missing else condition --- src/Common/Influx/InfluxClient.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Common/Influx/InfluxClient.ts b/src/Common/Influx/InfluxClient.ts index 9f620637..1b9b1eec 100644 --- a/src/Common/Influx/InfluxClient.ts +++ b/src/Common/Influx/InfluxClient.ts @@ -28,7 +28,7 @@ const extendLogger = (logger: Logger, suppressWriteWarnings = true): InfluxLogge warn: (message: string, err?: any) => { if(suppressWriteWarnings && !message.includes('Write to InfluxDB failed (attempt')) { logger.warn(message, err); - } else { + } else if(!suppressWriteWarnings) { logger.warn(message, err); } } From 122d5fb2afdb280725fb61e1050d95954728b301 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 17 Oct 2022 14:16:34 -0400 Subject: [PATCH 16/17] docs: Fix malformed URLs Fixes #114 --- docs/subreddit/components/attribution/README.md | 2 +- docs/subreddit/components/author/README.md | 12 ++++++------ docs/subreddit/components/history/README.md | 4 ++-- .../components/recentActivity/README.md | 4 ++-- docs/subreddit/components/regex/README.md | 16 ++++++++-------- .../components/repeatActivity/README.md | 4 ++-- .../components/subredditReady/README.md | 12 ++++++------ docs/subreddit/components/userNotes/README.md | 4 ++-- 8 files changed, 29 insertions(+), 29 deletions(-) diff --git a/docs/subreddit/components/attribution/README.md b/docs/subreddit/components/attribution/README.md index f6749472..776065ce 100644 --- a/docs/subreddit/components/attribution/README.md +++ b/docs/subreddit/components/attribution/README.md @@ -10,5 +10,5 @@ Consult the [schema](https://json-schema.app/view/%23/%23%2Fdefinitions%2FCheckJ ### Examples -* Self Promotion as percentage of all Activities [YAML](/docs/subreddit/componentscomponents/attribution/redditSelfPromoAll.yaml) | [JSON](/docs/subreddit/componentscomponents/attribution/redditSelfPromoAll.json5) - Check if Author is submitting much more than they comment. +* Self Promotion as percentage of all Activities [YAML](/docs/subreddit/components/attribution/redditSelfPromoAll.yaml) | [JSON](/docs/subreddit/components/attribution/redditSelfPromoAll.json5) - Check if Author is submitting much more than they comment. * Self Promotion as percentage of Submissions [YAML](/docs/subreddit/components/attribution/redditSelfPromoSubmissionsOnly.yaml) | [JSON](/docs/examplesm/attribution/redditSelfPromoSubmissionsOnly.json5) - Check if any of Author's aggregated submission origins are >10% of their submissions diff --git a/docs/subreddit/components/author/README.md b/docs/subreddit/components/author/README.md index 172d12eb..80f41746 100644 --- a/docs/subreddit/components/author/README.md +++ b/docs/subreddit/components/author/README.md @@ -9,7 +9,7 @@ The **Author** rule triggers if any [AuthorCriteria](https://json-schema.app/vie * author's subreddit flair text * author's subreddit flair css * author's subreddit mod status -* [Toolbox User Notes](/docs/subreddit/componentscomponents/userNotes) +* [Toolbox User Notes](/docs/subreddit/components/userNotes) The Author **Rule** is best used in conjunction with other Rules to short-circuit a Check based on who the Author is. It is easier to use a Rule to do this then to write **author filters** for every Rule (and makes Rules more re-useable). @@ -18,10 +18,10 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FAuthorRule ### Examples * Basic examples - * Flair new user Submission [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - If the Author does not have the `vet` flair then flair the Submission with `New User` - * Flair vetted user Submission [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - If the Author does have the `vet` flair then flair the Submission with `Vetted` + * Flair new user Submission [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - If the Author does not have the `vet` flair then flair the Submission with `New User` + * Flair vetted user Submission [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - If the Author does have the `vet` flair then flair the Submission with `Vetted` * Used with other Rules - * Ignore vetted user [YAML](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/author/flairNewUserSubmission.json5) - Short-circuit the Check if the Author has the `vet` flair + * Ignore vetted user [YAML](/docs/subreddit/components/author/flairNewUserSubmission.yaml) | [JSON](/docs/subreddit/components/author/flairNewUserSubmission.json5) - Short-circuit the Check if the Author has the `vet` flair ## Filter @@ -35,7 +35,7 @@ All **Rules** and **Checks** have an optional `authorIs` property that takes an ### Examples -* Skip recent activity check based on author [YAML](/docs/subreddit/componentscomponents/author/authorFilter.yaml) | [JSON](/docs/subreddit/componentscomponents/author/authorFilter.json5) - Skip a Recent Activity check for a set of subreddits if the Author of the Submission has any set of flairs. +* Skip recent activity check based on author [YAML](/docs/subreddit/components/author/authorFilter.yaml) | [JSON](/docs/subreddit/components/author/authorFilter.json5) - Skip a Recent Activity check for a set of subreddits if the Author of the Submission has any set of flairs. ## Flair users and submissions @@ -45,4 +45,4 @@ Consult [User Flair schema](https://json-schema.app/view/%23%2Fdefinitions%2FUse ### Examples -* OnlyFans submissions [YAML](/docs/subreddit/componentscomponents/author/onlyfansFlair.yaml) | [JSON](/docs/subreddit/componentscomponents/author/onlyfansFlair.json5) - Check whether submitter has typical OF keywords in their profile and flair both author + submission accordingly. +* OnlyFans submissions [YAML](/docs/subreddit/components/author/onlyfansFlair.yaml) | [JSON](/docs/subreddit/components/author/onlyfansFlair.json5) - Check whether submitter has typical OF keywords in their profile and flair both author + submission accordingly. diff --git a/docs/subreddit/components/history/README.md b/docs/subreddit/components/history/README.md index 9cf8ab74..9b5d711a 100644 --- a/docs/subreddit/components/history/README.md +++ b/docs/subreddit/components/history/README.md @@ -46,5 +46,5 @@ Example: ### Examples -* Low Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/lowEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/lowEngagement.json5) - Check if Author is submitting much more than they comment. -* OP Comment Engagement [YAML](/docs/subreddit/componentscomponents/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/componentscomponents/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content +* Low Comment Engagement [YAML](/docs/subreddit/components/history/lowEngagement.yaml) | [JSON](/docs/subreddit/components/history/lowEngagement.json5) - Check if Author is submitting much more than they comment. +* OP Comment Engagement [YAML](/docs/subreddit/components/history/opOnlyEngagement.yaml) | [JSON](/docs/subreddit/components/history/opOnlyEngagement.json5) - Check if Author is mostly engaging only in their own content diff --git a/docs/subreddit/components/recentActivity/README.md b/docs/subreddit/components/recentActivity/README.md index ed2a3079..81d27c3d 100644 --- a/docs/subreddit/components/recentActivity/README.md +++ b/docs/subreddit/components/recentActivity/README.md @@ -27,5 +27,5 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FRecentActi ### Examples -* Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits -* Submission in Free Karma Subreddits [YAML](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/componentscomponents/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently +* Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarma.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarma.json5) - Check if the Author has recently posted in any "free karma" subreddits +* Submission in Free Karma Subreddits [YAML](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.yaml) | [JSON](/docs/subreddit/components/recentActivity/freeKarmaOnSubmission.json5) - Check if the Author has posted the Submission this check is running on in any "free karma" subreddits recently diff --git a/docs/subreddit/components/regex/README.md b/docs/subreddit/components/regex/README.md index 1c142198..832a4251 100644 --- a/docs/subreddit/components/regex/README.md +++ b/docs/subreddit/components/regex/README.md @@ -11,12 +11,12 @@ Which can then be used in conjunction with a [`window`](https://github.com/FoxxM ### Examples -* Trigger if regex matches against the current activity - [YAML](/docs/subreddit/componentscomponents/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchAnyCurrentActivity.json5) -* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchThresholdCurrentActivity.json5) -* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubmissionParts.json5) -* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchHistoryActivity.json5) -* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/componentscomponents/regex/matchActivityThresholdHistory.json5) -* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchTotalHistoryActivity.json5) -* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/matchSubsetHistoryActivity.json5) -* Remove comments that are spamming discord links - [YAML](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/regex/removeDiscordSpam.json5) +* Trigger if regex matches against the current activity - [YAML](/docs/subreddit/components/regex/matchAnyCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchAnyCurrentActivity.json5) +* Trigger if regex matches 5 times against the current activity - [YAML](/docs/subreddit/components/regex/matchThresholdCurrentActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchThresholdCurrentActivity.json5) +* Trigger if regex matches against any part of a Submission - [YAML](/docs/subreddit/components/regex/matchSubmissionParts.yaml) | [JSON](/docs/subreddit/components/regex/matchSubmissionParts.json5) +* Trigger if regex matches any of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchHistoryActivity.json5) +* Trigger if regex matches at least 3 of Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5) | [JSON](/docs/subreddit/components/regex/matchActivityThresholdHistory.json5) +* Trigger if there are 5 regex matches in the Author's last 10 activities - [YAML](/docs/subreddit/components/regex/matchTotalHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchTotalHistoryActivity.json5) +* Trigger if there are 5 regex matches in the Author's last 10 comments - [YAML](/docs/subreddit/components/regex/matchSubsetHistoryActivity.yaml) | [JSON](/docs/subreddit/components/regex/matchSubsetHistoryActivity.json5) +* Remove comments that are spamming discord links - [YAML](/docs/subreddit/components/regex/removeDiscordSpam.yaml) | [JSON](/docs/subreddit/components/regex/removeDiscordSpam.json5) * Differs from just using automod because this config can allow one-off/organic links from users who DO NOT spam discord links but will still remove the comment if the user is spamming them diff --git a/docs/subreddit/components/repeatActivity/README.md b/docs/subreddit/components/repeatActivity/README.md index 5a47b6d5..9a38f792 100644 --- a/docs/subreddit/components/repeatActivity/README.md +++ b/docs/subreddit/components/repeatActivity/README.md @@ -47,5 +47,5 @@ With only `gapAllowance: 2` this rule **would trigger** because the the 1 and 2 ## Examples -* Crosspost Spamming [JSON](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits -* Burst-posting [JSON](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/componentscomponents/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts +* Crosspost Spamming [JSON](/docs/subreddit/components/repeatActivity/crosspostSpamming.json5) | [YAML](/docs/subreddit/components/repeatActivity/crosspostSpamming.yaml) - Check if an Author is spamming their Submissions across multiple subreddits +* Burst-posting [JSON](/docs/subreddit/components/repeatActivity/burstPosting.json5) | [YAML](/docs/subreddit/components/repeatActivity/burstPosting.yaml) - Check if Author is crossposting their Submissions in short bursts diff --git a/docs/subreddit/components/subredditReady/README.md b/docs/subreddit/components/subredditReady/README.md index 7eedf67c..5e5492d6 100644 --- a/docs/subreddit/components/subredditReady/README.md +++ b/docs/subreddit/components/subredditReady/README.md @@ -17,25 +17,25 @@ All actions for these configurations are non-destructive in that: ### Remove submissions from users who have used 'freekarma' subs to bypass karma checks -[YAML](/docs/subreddit/componentscomponents/subredditReady/freekarma.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/freekarma.json5) +[YAML](/docs/subreddit/components/subredditReady/freekarma.yaml) | [JSON](/docs/subreddit/components/subredditReady/freekarma.json5) If the user has any activity (comment/submission) in known freekarma subreddits in the past (50 activities or 6 months) then remove the submission. ### Remove submissions from users who have crossposted the same submission 4 or more times -[YAML](/docs/subreddit/componentscomponents/subredditReady/crosspostSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/crosspostSpam.yaml) +[YAML](/docs/subreddit/components/subredditReady/crosspostSpam.yaml) | [JSON](/docs/subreddit/components/subredditReady/crosspostSpam.yaml) If the user has crossposted the same submission in the past (50 activities or 6 months) 4 or more times in a row then remove the submission. ### Remove submissions from users who have crossposted or used 'freekarma' subs -[YAML](/docs/subreddit/componentscomponents/subredditReady/freeKarmaOrCrosspostSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/freeKarmaOrCrosspostSpam.json5) +[YAML](/docs/subreddit/componentsc/subredditReady/freeKarmaOrCrosspostSpam.yaml) | [JSON](/docs/subreddit/components/subredditReady/freeKarmaOrCrosspostSpam.json5) Will remove submission if either of the above two behaviors is detected ### Remove link submissions where the user's history is comprised of 10% or more of the same link -[YAML](/docs/subreddit/componentscomponents/subredditReady/selfPromo.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/selfPromo.json5) +[YAML](/docs/subreddit/components/subredditReady/selfPromo.yaml) | [JSON](/docs/subreddit/components/subredditReady/selfPromo.json5) If the link origin (youtube author, twitter author, etc. or regular domain for non-media links) @@ -48,13 +48,13 @@ then remove the submission ### Remove comment if the user has posted the same comment 4 or more times in a row -[YAML](/docs/subreddit/componentscomponents/subredditReady/commentSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/commentSpam.json5) +[YAML](/docs/subreddit/components/subredditReady/commentSpam.yaml) | [JSON](/docs/subreddit/components/subredditReady/commentSpam.json5) If the user made the same comment (with some fuzzy matching) 4 or more times in a row in the past (50 activities or 6 months) then remove the comment. ### Remove comment if it is discord invite link spam -[YAML](/docs/subreddit/componentscomponents/subredditReady/discordSpam.yaml) | [JSON](/docs/subreddit/componentscomponents/subredditReady/discordSpam.json5) +[YAML](/docs/subreddit/components/subredditReady/discordSpam.yaml) | [JSON](/docs/subreddit/components/subredditReady/discordSpam.json5) This rule goes a step further than automod can by being more discretionary about how it handles this type of spam. diff --git a/docs/subreddit/components/userNotes/README.md b/docs/subreddit/components/userNotes/README.md index 7af33a71..5b1c4a08 100644 --- a/docs/subreddit/components/userNotes/README.md +++ b/docs/subreddit/components/userNotes/README.md @@ -24,7 +24,7 @@ Consult the [schema](https://json-schema.app/view/%23%2Fdefinitions%2FUserNoteCr ### Examples -* Do not tag user with Good User note [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteFilter.yaml) +* Do not tag user with Good User note [JSON](/docs/subreddit/components/userNotes/usernoteFilter.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteFilter.yaml) ## Action @@ -33,4 +33,4 @@ A User Note can also be added to the Author of a Submission or Comment with the ### Examples -* Add note on user doing self promotion [JSON](/docs/subreddit/componentscomponents/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/componentscomponents/userNotes/usernoteSP.yaml) +* Add note on user doing self promotion [JSON](/docs/subreddit/components/userNotes/usernoteSP.json5) | [YAML](/docs/subreddit/components/userNotes/usernoteSP.yaml) From acbb9a862646bc43efd79b0a5b8ecff5de3d0811 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 17 Oct 2022 15:30:56 -0400 Subject: [PATCH 17/17] feat(usernote): Improve usernote filtering functionality Same as modActions... * Add `referencesCurrentActivity` boolean to filter by notes associated with current activity * Add `note` string property to filter by note content (string or regular expression) * Replace `allowDuplicates` with `existingNoteCheck` on UserNoteAction to allow for more granular note control on action --- docs/subreddit/components/README.md | 3 +- src/Action/UserNoteAction.ts | 96 +++++++++++---- .../Infrastructure/Filters/FilterCriteria.ts | 37 ++++++ src/Common/defaults.ts | 2 +- src/Schema/Action.json | 34 +++++- src/Schema/App.json | 34 +++++- src/Schema/Check.json | 34 +++++- src/Schema/OperatorConfig.json | 115 ++++++++++++++++++ src/Schema/Rule.json | 17 +++ src/Schema/RuleSet.json | 17 +++ src/Schema/Run.json | 34 +++++- src/Subreddit/SubredditResources.ts | 27 ++-- src/Subreddit/UserNotes.ts | 41 +++++++ 13 files changed, 444 insertions(+), 47 deletions(-) diff --git a/docs/subreddit/components/README.md b/docs/subreddit/components/README.md index c4969838..c2200673 100644 --- a/docs/subreddit/components/README.md +++ b/docs/subreddit/components/README.md @@ -754,7 +754,7 @@ actions: - kind: usernote type: spamwarn content: 'Usernote message' - allowDuplicate: boolean # if false then the usernote will not be added if the same note appears for this activity + existingNoteCheck: boolean # if true (default) then the usernote will not be added if the same note appears for this activity ``` ### Mod Note @@ -779,6 +779,7 @@ actions: type: SPAM_WATCH content: 'a note only mods can see message' # optional referenceActivity: boolean # if true the Note will be linked to the Activity being processed + existingNoteCheck: boolean # if true (default) then the note will not be added if the same note appears for this activity ``` # Filters diff --git a/src/Action/UserNoteAction.ts b/src/Action/UserNoteAction.ts index 87f46b7d..dba97ed5 100644 --- a/src/Action/UserNoteAction.ts +++ b/src/Action/UserNoteAction.ts @@ -1,7 +1,6 @@ import {ActionJson, ActionConfig, ActionOptions} from "./index"; import Action from "./index"; import {Comment} from "snoowrap"; -import {renderContent} from "../Utils/SnoowrapUtils"; import {UserNoteJson} from "../Subreddit/UserNotes"; import Submission from "snoowrap/dist/objects/Submission"; import {ActionProcessResult, RuleResult} from "../Common/interfaces"; @@ -9,19 +8,34 @@ import {RuleResultEntity} from "../Common/Entities/RuleResultEntity"; import {runCheckOptions} from "../Subreddit/Manager"; import {ActionTypes, UserNoteType} from "../Common/Infrastructure/Atomic"; import {ActionResultEntity} from "../Common/Entities/ActionResultEntity"; +import { + FullUserNoteCriteria, + toFullUserNoteCriteria, UserNoteCriteria +} from "../Common/Infrastructure/Filters/FilterCriteria"; +import {buildFilterCriteriaSummary} from "../util"; export class UserNoteAction extends Action { content: string; type: UserNoteType; - allowDuplicate: boolean; + existingNoteCheck?: UserNoteCriteria constructor(options: UserNoteActionOptions) { super(options); - const {type, content = '', allowDuplicate = false} = options; + const {type, content = '', existingNoteCheck = true, allowDuplicate} = options; this.type = type; this.content = content; - this.allowDuplicate = allowDuplicate; + if(typeof existingNoteCheck !== 'boolean') { + this.existingNoteCheck = existingNoteCheck; + } else { + let exNotecheck: boolean; + if(allowDuplicate !== undefined) { + exNotecheck = !allowDuplicate; + } else { + exNotecheck = existingNoteCheck; + } + this.existingNoteCheck = this.generateCriteriaFromDuplicateConvenience(exNotecheck); + } } getKind(): ActionTypes { @@ -33,25 +47,30 @@ export class UserNoteAction extends Action { const renderedContent = (await this.renderContent(this.content, item, ruleResults, actionResults) as string); this.logger.verbose(`Note:\r\n(${this.type}) ${renderedContent}`); - if (!this.allowDuplicate) { - const notes = await this.resources.userNotes.getUserNotes(item.author); - let existingNote = notes.find((x) => x.link !== null && x.link.includes(item.id)); - if(existingNote === undefined && notes.length > 0) { - const lastNote = notes[notes.length - 1]; - // possibly notes don't have a reference link so check if last one has same text - if(lastNote.link === null && lastNote.text === renderedContent) { - existingNote = lastNote; - } - } - if (existingNote !== undefined && existingNote.noteType === this.type) { - this.logger.info(`Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false`); - return { - dryRun, - success: false, - result: `Will not add note because one already exists for this Activity (${existingNote.time.local().format()}) and allowDuplicate=false` - }; - } + let noteCheckPassed: boolean = true; + let noteCheckResult: undefined | string; + + if(this.existingNoteCheck === undefined) { + // nothing to do! + noteCheckResult = 'existingNoteCheck=false so no existing note checks were performed.'; + } else { + const noteCheckCriteriaResult = await this.resources.isAuthor(item, { + userNotes: [this.existingNoteCheck] + }); + noteCheckPassed = noteCheckCriteriaResult.passed; + const {details} = buildFilterCriteriaSummary(noteCheckCriteriaResult); + noteCheckResult = `${noteCheckPassed ? 'Existing note check condition succeeded' : 'Will not add note because existing note check condition failed'} -- ${details.join(' ')}`; + } + + this.logger.info(noteCheckResult); + if (!noteCheckPassed) { + return { + dryRun, + success: false, + result: noteCheckResult + }; } + if (!dryRun) { await this.resources.userNotes.addUserNote(item, this.type, renderedContent, this.name !== undefined ? `(Action ${this.name})` : ''); } else if (!await this.resources.userNotes.warningExists(this.type)) { @@ -64,11 +83,23 @@ export class UserNoteAction extends Action { } } + generateCriteriaFromDuplicateConvenience(val: boolean): UserNoteCriteria | undefined { + if(val) { + return { + type: this.type, + note: this.content !== '' && this.content !== undefined && this.content !== null ? [this.content] : undefined, + search: 'current', + count: '< 1' + }; + } + return undefined; + } + protected getSpecificPremise(): object { return { content: this.content, type: this.type, - allowDuplicate: this.allowDuplicate + existingNoteCheck: this.existingNoteCheck } } } @@ -76,10 +107,29 @@ export class UserNoteAction extends Action { export interface UserNoteActionConfig extends ActionConfig,UserNoteJson { /** * Add Note even if a Note already exists for this Activity + * + * USE `existingNoteCheck` INSTEAD + * * @examples [false] * @default false + * @deprecated * */ allowDuplicate?: boolean, + + /** + * Check if there is an existing Note matching some criteria before adding the Note. + * + * If this check passes then the Note is added. The value may be a boolean or UserNoteCriteria. + * + * Boolean convenience: + * + * * If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria + * * If `false` then no check is performed and Note is always added + * + * @examples [true] + * @default true + * */ + existingNoteCheck?: boolean | UserNoteCriteria, } export interface UserNoteActionOptions extends Omit, ActionOptions { diff --git a/src/Common/Infrastructure/Filters/FilterCriteria.ts b/src/Common/Infrastructure/Filters/FilterCriteria.ts index 48dfcc0e..9fd137bf 100644 --- a/src/Common/Infrastructure/Filters/FilterCriteria.ts +++ b/src/Common/Infrastructure/Filters/FilterCriteria.ts @@ -119,6 +119,34 @@ export interface UserNoteCriteria extends UserSubredditHistoryCriteria { * @examples ["spamwarn"] * */ type: string; + /** + * The content of the Note to search For. + * + * * Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content. + * * Can also be Regular Expression if wrapped in forward slashes IE '\/test.*\/i' + * */ + note?: string | string[] + /* + * Does this note link to the currently processing Activity? + * */ + referencesCurrentActivity?: boolean +} + +export interface FullUserNoteCriteria extends Omit { + note?: RegExp[] +} + +export const toFullUserNoteCriteria = (val: UserNoteCriteria): FullUserNoteCriteria => { + const {note} = val; + let notesVal = undefined; + if (note !== undefined) { + const notesArr = Array.isArray(note) ? note : [note]; + notesVal = notesArr.map(x => parseStringToRegexOrLiteralSearch(x)); + } + return { + ...val, + note: notesVal + } } export interface ModActionCriteria extends UserSubredditHistoryCriteria { @@ -130,6 +158,9 @@ export interface ModActionCriteria extends UserSubredditHistoryCriteria { export interface FullModActionCriteria extends Omit { type?: ModActionType[] count?: GenericComparison + /* + * Does this action/note link to the currently processing Activity? + * */ activityType?: MaybeActivityType[] } @@ -140,6 +171,12 @@ export interface ModNoteCriteria extends ModActionCriteria { export interface FullModNoteCriteria extends FullModActionCriteria, Omit { noteType?: ModUserNoteLabel[] + /** + * The content of the Note to search For. + * + * * Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content. + * * Can also be Regular Expression if wrapped in forward slashes IE '\/test.*\/i' + * */ note?: RegExp[] } diff --git a/src/Common/defaults.ts b/src/Common/defaults.ts index 67e2f324..4499b16b 100644 --- a/src/Common/defaults.ts +++ b/src/Common/defaults.ts @@ -45,4 +45,4 @@ export const filterCriteriaDefault: FilterCriteriaDefaults = { export const defaultDataDir = path.resolve(__dirname, '../..'); export const defaultConfigFilenames = ['config.json', 'config.yaml']; -export const VERSION = '0.13.1'; +export const VERSION = '0.13.2'; diff --git a/src/Schema/Action.json b/src/Schema/Action.json index be0ff715..e815ba7c 100644 --- a/src/Schema/Action.json +++ b/src/Schema/Action.json @@ -2972,7 +2972,7 @@ "properties": { "allowDuplicate": { "default": false, - "description": "Add Note even if a Note already exists for this Activity", + "description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD", "examples": [ false ], @@ -3028,6 +3028,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/UserNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -3095,6 +3110,23 @@ "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?asc.*|desc.*)*$", "type": "string" }, + "note": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'" + }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/App.json b/src/Schema/App.json index c7a54ea7..545c2b5a 100644 --- a/src/Schema/App.json +++ b/src/Schema/App.json @@ -6854,7 +6854,7 @@ "properties": { "allowDuplicate": { "default": false, - "description": "Add Note even if a Note already exists for this Activity", + "description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD", "examples": [ false ], @@ -6910,6 +6910,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/UserNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -6977,6 +6992,23 @@ "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?asc.*|desc.*)*$", "type": "string" }, + "note": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'" + }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/Check.json b/src/Schema/Check.json index 65a7500b..5c3d9d7b 100644 --- a/src/Schema/Check.json +++ b/src/Schema/Check.json @@ -6270,7 +6270,7 @@ "properties": { "allowDuplicate": { "default": false, - "description": "Add Note even if a Note already exists for this Activity", + "description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD", "examples": [ false ], @@ -6326,6 +6326,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/UserNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -6393,6 +6408,23 @@ "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?asc.*|desc.*)*$", "type": "string" }, + "note": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'" + }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/OperatorConfig.json b/src/Schema/OperatorConfig.json index c0a47279..9de08f98 100644 --- a/src/Schema/OperatorConfig.json +++ b/src/Schema/OperatorConfig.json @@ -968,8 +968,18 @@ "credentials": { "$ref": "#/definitions/InfluxCredentials" }, + "debug": { + "type": "boolean" + }, "defaultTags": { "$ref": "#/definitions/Record" + }, + "useKeepAliveAgent": { + "type": "boolean" + }, + "writeOptions": { + "$ref": "#/definitions/WriteOptions", + "description": "Options used by{@linkWriteApi}." } }, "required": [ @@ -2283,6 +2293,23 @@ "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?asc.*|desc.*)*$", "type": "string" }, + "note": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'" + }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", @@ -2342,6 +2369,94 @@ } }, "type": "object" + }, + "WriteOptions": { + "description": "Options used by{@linkWriteApi}.", + "properties": { + "batchSize": { + "description": "max number of records/lines to send in a batch", + "type": "number" + }, + "consistency": { + "description": "InfluxDB Enterprise write consistency as explained in https://docs.influxdata.com/enterprise_influxdb/v1.9/concepts/clustering/#write-consistency", + "enum": [ + "all", + "any", + "one", + "quorum" + ], + "type": "string" + }, + "defaultTags": { + "$ref": "#/definitions/Record", + "description": "default tags, unescaped" + }, + "exponentialBase": { + "description": "base for the exponential retry delay", + "type": "number" + }, + "flushInterval": { + "description": "delay between data flushes in milliseconds, at most `batch size` records are sent during flush", + "type": "number" + }, + "gzipThreshold": { + "description": "When specified, write bodies larger than the threshold are gzipped", + "type": "number" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers that will be sent with every write request", + "type": "object" + }, + "maxBatchBytes": { + "description": "max size of a batch in bytes", + "type": "number" + }, + "maxBufferLines": { + "description": "the maximum size of retry-buffer (in lines)", + "type": "number" + }, + "maxRetries": { + "description": "max count of retries after the first write fails", + "type": "number" + }, + "maxRetryDelay": { + "description": "maximum delay when retrying write (milliseconds)", + "type": "number" + }, + "maxRetryTime": { + "description": "max time (millis) that can be spent with retries", + "type": "number" + }, + "minRetryDelay": { + "description": "minimum delay when retrying write (milliseconds)", + "type": "number" + }, + "randomRetry": { + "description": "randomRetry indicates whether the next retry delay is deterministic (false) or random (true).\nThe deterministic delay starts with `minRetryDelay * exponentialBase` and it is multiplied\nby `exponentialBase` until it exceeds `maxRetryDelay`.\nWhen random is `true`, the next delay is computed as a random number between next retry attempt (upper)\nand the lower number in the deterministic sequence. `random(retryJitter)` is added to every returned value.", + "type": "boolean" + }, + "retryJitter": { + "description": "add `random(retryJitter)` milliseconds delay when retrying HTTP calls", + "type": "number" + } + }, + "required": [ + "batchSize", + "exponentialBase", + "flushInterval", + "maxBatchBytes", + "maxBufferLines", + "maxRetries", + "maxRetryDelay", + "maxRetryTime", + "minRetryDelay", + "randomRetry", + "retryJitter" + ], + "type": "object" } }, "description": "Configuration for application-level settings IE for running the bot instance\n\n* To load a JSON configuration **from the command line** use the `-c` cli argument EX: `node src/index.js -c /path/to/JSON/config.json`\n* To load a JSON configuration **using an environmental variable** use `OPERATOR_CONFIG` EX: `OPERATOR_CONFIG=/path/to/JSON/config.json`", diff --git a/src/Schema/Rule.json b/src/Schema/Rule.json index 7bb32854..83275bd8 100644 --- a/src/Schema/Rule.json +++ b/src/Schema/Rule.json @@ -3945,6 +3945,23 @@ "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?asc.*|desc.*)*$", "type": "string" }, + "note": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'" + }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/RuleSet.json b/src/Schema/RuleSet.json index c70f4c57..aabf25a1 100644 --- a/src/Schema/RuleSet.json +++ b/src/Schema/RuleSet.json @@ -3910,6 +3910,23 @@ "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?asc.*|desc.*)*$", "type": "string" }, + "note": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'" + }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Schema/Run.json b/src/Schema/Run.json index 4db39a9a..a1d0c43e 100644 --- a/src/Schema/Run.json +++ b/src/Schema/Run.json @@ -6467,7 +6467,7 @@ "properties": { "allowDuplicate": { "default": false, - "description": "Add Note even if a Note already exists for this Activity", + "description": "Add Note even if a Note already exists for this Activity\n\nUSE `existingNoteCheck` INSTEAD", "examples": [ false ], @@ -6523,6 +6523,21 @@ ], "type": "boolean" }, + "existingNoteCheck": { + "anyOf": [ + { + "$ref": "#/definitions/UserNoteCriteria" + }, + { + "type": "boolean" + } + ], + "default": true, + "description": "Check if there is an existing Note matching some criteria before adding the Note.\n\nIf this check passes then the Note is added. The value may be a boolean or UserNoteCriteria.\n\nBoolean convenience:\n\n* If `true` or undefined then CM generates a UserNoteCriteria that passes only if there is NO existing note matching note criteria\n* If `false` then no check is performed and Note is always added", + "examples": [ + true + ] + }, "itemIs": { "anyOf": [ { @@ -6590,6 +6605,23 @@ "pattern": "^\\s*(?>|>=|<|<=)\\s*(?\\d+)\\s*(?%?)\\s*(?in\\s+\\d+\\s*(days?|weeks?|months?|years?|hours?|minutes?|seconds?|milliseconds?))?\\s*(?asc.*|desc.*)*$", "type": "string" }, + "note": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "string" + } + ], + "description": "The content of the Note to search For.\n\n* Can be a single string or list of strings to search for. Each string will be searched for case-insensitive, as a subset of note content.\n* Can also be Regular Expression if wrapped in forward slashes IE '\\/test.*\\/i'" + }, + "referencesCurrentActivity": { + "type": "boolean" + }, "search": { "default": "current", "description": "How to test the Toolbox Notes or Mod Actions for this Author:\n\n### current\n\nOnly the most recent note is checked for criteria\n\n### total\n\n`count` comparison of mod actions/notes must be found within all history\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* EX: `count: > 3 in 1 week` => Must have more than 3 notes within the last week\n\n### consecutive\n\nThe `count` **number** of mod actions/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", diff --git a/src/Subreddit/SubredditResources.ts b/src/Subreddit/SubredditResources.ts index c472206e..2dde0d9a 100644 --- a/src/Subreddit/SubredditResources.ts +++ b/src/Subreddit/SubredditResources.ts @@ -123,7 +123,7 @@ import { SubmissionState, SubredditCriteria, toFullModLogCriteria, - toFullModNoteCriteria, + toFullModNoteCriteria, toFullUserNoteCriteria, TypedActivityState, TypedActivityStates, UserNoteCriteria @@ -3235,10 +3235,11 @@ export class SubredditResources { } break; case 'userNotes': + const unCriterias = (authorOpts[k] as UserNoteCriteria[]).map(x => toFullUserNoteCriteria(x)); const notes = await this.userNotes.getUserNotes(item.author); let foundNoteResult: string[] = []; const notePass = () => { - for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) { + for (const noteCriteria of unCriterias) { const {count = '>= 1', search = 'current', type} = noteCriteria; const { value, @@ -3247,26 +3248,14 @@ export class SubredditResources { duration, extra = '' } = parseGenericValueOrPercentComparison(count); - const cutoffDate = duration === undefined ? undefined : dayjs().subtract(duration); const order = extra.includes('asc') ? 'ascending' : 'descending'; switch (search) { - case 'current': - if (notes.length > 0) { - const currentNoteType = notes[notes.length - 1].noteType; - foundNoteResult.push(`Current => ${currentNoteType}`); - if (currentNoteType === type) { - return true; - } - } else { - foundNoteResult.push('No notes present'); - } - break; case 'consecutive': if (isPercent) { throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`); } - let orderedNotes = cutoffDate === undefined ? notes : notes.filter(x => x.time.isSameOrAfter(cutoffDate)); + let orderedNotes = [...notes]; if (order === 'descending') { orderedNotes = [...notes]; orderedNotes.reverse(); @@ -3274,7 +3263,7 @@ export class SubredditResources { let currCount = 0; let maxCount = 0; for (const note of orderedNotes) { - if (note.noteType === type) { + if(note.matches(noteCriteria, item)) { currCount++; maxCount = Math.max(maxCount, currCount); } else { @@ -3286,8 +3275,10 @@ export class SubredditResources { return true; } break; + case 'current': case 'total': - const filteredNotes = notes.filter(x => x.noteType === type && cutoffDate === undefined || (x.time.isSameOrAfter(cutoffDate))); + const notesToUse = search === 'current' ? [notes[notes.length - 1]] : notes; + const filteredNotes = notesToUse.filter(x => x.matches(noteCriteria, item)); if (isPercent) { // avoid divide by zero const percent = notes.length === 0 ? 0 : filteredNotes.length / notes.length; @@ -3297,7 +3288,7 @@ export class SubredditResources { } } else { foundNoteResult.push(`${filteredNotes.length} are ${type}`); - if (comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) { + if (comparisonTextOp(filteredNotes.length, operator, value)) { return true; } } diff --git a/src/Subreddit/UserNotes.ts b/src/Subreddit/UserNotes.ts index e41ace58..da5078f6 100644 --- a/src/Subreddit/UserNotes.ts +++ b/src/Subreddit/UserNotes.ts @@ -16,6 +16,9 @@ import {Cache} from 'cache-manager'; import {isScopeError} from "../Utils/Errors"; import {ErrorWithCause} from "pony-cause"; import {UserNoteType} from "../Common/Infrastructure/Atomic"; +import {FullUserNoteCriteria, UserNoteCriteria} from "../Common/Infrastructure/Filters/FilterCriteria"; +import {parseGenericValueOrPercentComparison} from "../Common/Infrastructure/Comparisons"; +import {SnoowrapActivity} from "../Common/Infrastructure/Reddit"; interface RawUserNotesPayload { ver: number, @@ -251,6 +254,44 @@ export class UserNote { } + public matches(criteria: FullUserNoteCriteria, item?: SnoowrapActivity) { + if (criteria.type !== undefined) { + if(typeof this.noteType === 'string') { + if(this.noteType.toLowerCase() !== criteria.type.toLowerCase().trim()) { + return false + } + } else { + return false; + } + } + if (criteria.note !== undefined && !criteria.note.some(x => x.test(this.text ?? ''))) { + return false; + } + if(criteria.referencesCurrentActivity !== undefined) { + if(criteria.referencesCurrentActivity) { + if(item === undefined) { + return false; + } + if(this.link === null) { + return false; + } + if(!this.link.includes(item.id)) { + return false; + } + } else if(this.link !== null && item !== undefined && this.link.includes(item.id)) { + return false; + } + } + const {duration} = parseGenericValueOrPercentComparison(criteria.count ?? '>= 1'); + if (duration !== undefined) { + const cutoffDate = dayjs().subtract(duration); + if (this.time.isSameOrAfter(cutoffDate)) { + return false; + } + } + return true; + } + public toRaw(constants: UserNotesConstants): RawNote { let m = this.modIndex; if(m === undefined && this.moderator !== undefined) {