Compare commits

..

19 Commits
0.9.0 ... 0.9.2

Author SHA1 Message Date
FoxxMD
631e21452c Merge branch 'edge' 2021-09-28 16:36:13 -04:00
FoxxMD
be6fa4dd50 fix(cache): Fix accidental re-use of maps 2021-09-24 16:13:58 -04:00
FoxxMD
0d7a82836f refactor(cache): Move bot usage stats into cache
* Moving into cache means stats will persist after restart (yay!)
* Refactored stats structure to be simpler
2021-09-24 15:24:19 -04:00
FoxxMD
d9a59b6824 feat(recent): Print log statement when image processing is causing rule to take a long time 2021-09-23 13:23:58 -04:00
FoxxMD
ddbf8c3189 fix(recent): Actually use filtered activities when using submission as reference 2021-09-23 12:58:43 -04:00
FoxxMD
8393c471b2 fix(image): Dynamically import resemblejs for better compatibility on systems not supporting node-canvas
* By dynamically importing the module any user not using image comparison will not be affected by a lack of node-canvas dependency
* try-catch on import and provide a helpful error message about node-canvas dep
2021-09-23 10:38:34 -04:00
FoxxMD
fe66a2e8f7 fix(docker): Update build to build node-canvas from source 2021-09-23 10:08:26 -04:00
FoxxMD
4b0284102d fix: Improve image comparison threshold and results for typescript 2021-09-22 22:15:00 -04:00
FoxxMD
95529f14a8 feat(recent): Implement pixel-level image comparison when using a reference (image) submission 2021-09-22 16:52:56 -04:00
FoxxMD
26af2c4e4d fix(recent): don't include submission being checked when filtering by reference 2021-09-22 10:29:06 -04:00
FoxxMD
044c293f34 fix(attribution): Update aggregateOn defaults to align with expected behavior
Majority of mods that have used this rule assume it does not aggregate on reddit domains by default (only external links), which is reasonable.
So update the default to follow this assumption.
2021-09-22 10:11:25 -04:00
FoxxMD
a082c9e593 doc(attribution): Remove unused useSubmissionAsReference property 2021-09-22 09:36:44 -04:00
FoxxMD
4f3685a1f5 Merge branch 'edge' 2021-09-21 15:18:38 -04:00
FoxxMD
e242c36c09 fix(tooling): Fix tag pattern for git cliff 2021-09-21 15:18:26 -04:00
FoxxMD
d2d945db2c Merge branch 'edge' 2021-09-21 15:08:28 -04:00
FoxxMD
c5018183e0 fix(attribution): Improve parsing of domain type to fix bug with galleries
* Add `redditMedia` as distinct domain type from `self` for more granular aggregation
* Use `redditMedia` to fix bug where video and galleries were being counted as `media`
2021-09-20 16:34:29 -04:00
FoxxMD
c5358f196d feat(author): Handle shadowbanned users
* Allow checking if user is shadowbanned via authorIs (AuthorCriteria)
* try-catch on history get or author criteria to try to detect shadowbanned user for a more descriptive error
2021-09-20 13:49:35 -04:00
FoxxMD
1d9f8245f9 feat(tooling): scope-based sorting with BC note for git cliff generation 2021-09-20 11:51:25 -04:00
FoxxMD
20b37f3a40 Initial git cliff config 2021-09-20 11:03:37 -04:00
22 changed files with 2116 additions and 412 deletions

View File

@@ -4,6 +4,12 @@ ENV TZ=Etc/GMT
RUN apk update
# required dependencies in order to compile linux-musl (node-canvas) on alpine
# https://github.com/node-gfx/node-canvas-prebuilt/issues/77#issuecomment-884365161
RUN apk add --no-cache build-base g++ cairo-dev jpeg-dev pango-dev giflib-dev
# required dependencies in order to compile linux-musl (node-canvas) on alpine
RUN apk add --update --repository http://dl-3.alpinelinux.org/alpine/edge/testing libmount ttf-dejavu ttf-droid ttf-freefont ttf-liberation ttf-ubuntu-font-family fontconfig
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
WORKDIR /usr/app
@@ -11,7 +17,9 @@ WORKDIR /usr/app
COPY package*.json ./
COPY tsconfig.json .
RUN npm install
# no prebuild support for node-canvas on alpine so need to compile
# https://github.com/Automattic/node-canvas#compiling
RUN npm install --build-from-source
ADD . /usr/app

67
cliff.toml Normal file
View File

@@ -0,0 +1,67 @@
# configuration file for git-cliff (0.1.0)
[changelog]
# changelog header
header = """
# Changelog
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://tera.netlify.app/docs/#introduction
body = """
{% if version %}\
## [{{ version | replace(from="v", to="") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits
| filter(attribute="scope")
| sort(attribute="scope") %}
- *({{commit.scope}})* {{ commit.message | upper_first }}
{%- if commit.breaking %}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{%- endif -%}
{%- endfor -%}
{%- for commit in commits %}
{%- if commit.scope -%}
{% else -%}
- *(No Category)* {{ commit.message | upper_first }}
{% if commit.breaking -%}
{% raw %} {% endraw %}- **BREAKING**: {{commit.breaking_description}}
{% endif -%}
{% endif -%}
{% endfor -%}
{% endfor %}
"""
# remove the leading and trailing whitespaces from the template
trim = true
# changelog footer
footer = """
<!-- generated by git-cliff -->
"""
[git]
# allow only conventional commits
# https://www.conventionalcommits.org
conventional_commits = true
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "Features"},
{ message = "^fix", group = "Bug Fixes"},
{ message = "^doc", group = "Documentation"},
{ message = "^perf", group = "Performance"},
{ message = "^refactor", group = "Refactor"},
{ message = "^style", group = "Styling"},
{ message = "^test", group = "Testing"},
{ message = "^chore\\(release\\): prepare for", skip = true},
{ message = "^chore", group = "Miscellaneous Tasks"},
{ body = ".*security", group = "Security"},
]
# filter out the commits that are not matched by commit parsers
filter_commits = false
# glob pattern for matching git tags
tag_pattern = "[0-9]*"
# regex for skipping tags
skip_tags = "v0.1.0-beta.1"

1143
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -50,6 +50,7 @@
"got": "^11.8.2",
"he": "^1.2.0",
"http-proxy": "^1.18.1",
"image-size": "^1.0.0",
"js-yaml": "^4.1.0",
"json5": "^2.2.0",
"jsonwebtoken": "^8.5.1",
@@ -65,6 +66,7 @@
"passport-custom": "^1.1.1",
"passport-jwt": "^4.0.0",
"pretty-print-json": "^1.0.3",
"resemblejs": "^4.0.0",
"safe-stable-stringify": "^1.1.1",
"snoostorm": "^1.5.2",
"snoowrap": "^1.23.0",
@@ -101,10 +103,15 @@
"@types/object-hash": "^2.1.0",
"@types/passport": "^1.0.7",
"@types/passport-jwt": "^3.0.6",
"@types/pixelmatch": "^5.2.4",
"@types/resemblejs": "^3.2.1",
"@types/tcp-port-used": "^1.0.0",
"@types/triple-beam": "^1.3.2",
"ts-auto-guard": "*",
"ts-json-schema-generator": "^0.93.0",
"typescript-json-schema": "^0.50.1"
},
"optionalDependencies": {
"node-canvas": "^2.7.0"
}
}

View File

@@ -99,6 +99,13 @@ export interface AuthorCriteria {
* Does Author's account have a verified email?
* */
verified?: boolean
/**
* Is the author shadowbanned?
*
* This is determined by trying to retrieve the author's profile. If a 404 is returned it is likely they are shadowbanned
* */
shadowBanned?: boolean
}
export class Author implements AuthorCriteria {
@@ -112,6 +119,7 @@ export class Author implements AuthorCriteria {
linkKarma?: string;
totalKarma?: string;
verified?: boolean;
shadowBanned?: boolean;
constructor(options: AuthorCriteria) {
this.name = options.name;
@@ -123,6 +131,7 @@ export class Author implements AuthorCriteria {
this.commentKarma = options.commentKarma;
this.linkKarma = options.linkKarma;
this.totalKarma = options.totalKarma;
this.shadowBanned = options.shadowBanned;
}
}

View File

@@ -1,2 +1,31 @@
import {HistoricalStats} from "./interfaces";
export const cacheOptDefaults = {ttl: 60, max: 500, checkPeriod: 600};
export const cacheTTLDefaults = {authorTTL: 60, userNotesTTL: 300, wikiTTL: 300, submissionTTL: 60, commentTTL: 60, filterCriteriaTTL: 60, subredditTTL: 600};
export const historicalDefaults: HistoricalStats = {
eventsCheckedTotal: 0,
eventsActionedTotal: 0,
checksRun: new Map(),
checksFromCache: new Map(),
checksTriggered: new Map(),
rulesRun: new Map(),
//rulesCached: new Map(),
rulesCachedTotal: 0,
rulesTriggered: new Map(),
actionsRun: new Map(),
}
export const createHistoricalDefaults = (): HistoricalStats => {
return {
eventsCheckedTotal: 0,
eventsActionedTotal: 0,
checksRun: new Map(),
checksFromCache: new Map(),
checksTriggered: new Map(),
rulesRun: new Map(),
//rulesCached: new Map(),
rulesCachedTotal: 0,
rulesTriggered: new Map(),
actionsRun: new Map(),
};
}

View File

@@ -4,6 +4,8 @@ import {MESSAGE} from 'triple-beam';
import Poll from "snoostorm/out/util/Poll";
import Snoowrap from "snoowrap";
import {RuleResult} from "../Rule";
import {IncomingMessage} from "http";
import {ResembleSingleCallbackComparisonResult} from "resemblejs";
/**
* An ISO 8601 Duration
@@ -223,6 +225,59 @@ export interface ReferenceSubmission {
useSubmissionAsReference?: boolean,
}
/**
* When comparing submissions detect if the reference submission is an image and do a pixel-comparison to other detected image submissions.
*
* **Note:** This is an **experimental feature**
* */
export interface ImageDetection {
/**
* Is image detection enabled?
* */
enable?: boolean
/**
* Determines how and when to check if a URL is an image
*
* **Note:** After fetching a URL the **Content-Type** is validated to contain `image` before detection occurs
*
* **When `extension`:** (default)
*
* * Only URLs that end in known image extensions (.png, .jpg, etc...) are fetched
*
* **When `unknown`:**
*
* * URLs that end in known image extensions (.png, .jpg, etc...) are fetched
* * URLs with no extension or unknown (IE non-video, non-doc, etc...) are fetched
*
* **When `all`:**
*
* * All submissions that have URLs (non-self) will be fetched, regardless of extension
* * **Note:** This can be bandwidth/CPU intensive if history window is large so use with care
*
* @default "extension"
* */
fetchBehavior?: 'extension' | 'unknown' | 'all',
/**
* The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.
*
* Default is `5`
*
* @default 5
* */
threshold?: number
}
export interface ImageData {
data: Buffer,
width: number,
height: number
pixels: number
}
export interface ResembleResult extends ResembleSingleCallbackComparisonResult {
rawMisMatchPercentage: number
}
export interface RichContent {
/**
* The Content to submit for this Action. Content is interpreted as reddit-flavored Markdown.
@@ -1547,3 +1602,91 @@ export interface RedditEntity {
name: string
type: RedditEntityType
}
export interface StatusCodeError extends Error {
name: 'StatusCodeError',
statusCode: number,
message: string,
response: IncomingMessage,
error: Error
}
export interface HistoricalStatsDisplay extends HistoricalStats {
checksRunTotal: number
checksFromCacheTotal: number
checksTriggeredTotal: number
rulesRunTotal: number
rulesCachedTotal: number
rulesTriggeredTotal: number
actionsRunTotal: number
}
export interface HistoricalStats {
eventsCheckedTotal: number
eventsActionedTotal: number
checksRun: Map<string, number>
checksFromCache: Map<string, number>
checksTriggered: Map<string, number>
rulesRun: Map<string, number>
//rulesCached: Map<string, number>
rulesCachedTotal: number
rulesTriggered: Map<string, number>
actionsRun: Map<string, number>
[index: string]: any
}
export interface SubredditHistoricalStats {
allTime: HistoricalStats
lastReload: HistoricalStats
}
export interface SubredditHistoricalStatsDisplay {
allTime: HistoricalStatsDisplay
lastReload: HistoricalStatsDisplay
}
export interface ManagerStats {
// eventsCheckedTotal: number
// eventsCheckedSinceStartTotal: number
eventsAvg: number
// checksRunTotal: number
// checksRunSinceStartTotal: number
// checksTriggered: number
// checksTriggeredTotal: number
// checksTriggeredSinceStart: number
// checksTriggeredSinceStartTotal: number
// rulesRunTotal: number
// rulesRunSinceStartTotal: number
// rulesCachedTotal: number
// rulesCachedSinceStartTotal: number
// rulesTriggeredTotal: number
// rulesTriggeredSinceStartTotal: number
rulesAvg: number
// actionsRun: number
// actionsRunTotal: number
// actionsRunSinceStart: number,
// actionsRunSinceStartTotal: number
historical: SubredditHistoricalStatsDisplay
cache: {
provider: string,
currentKeyCount: number,
isShared: boolean,
totalRequests: number,
totalMiss: number,
missPercent: string,
requestRate: number,
types: ResourceStats
},
}
export interface HistoricalStatUpdateData {
eventsCheckedTotal?: number
eventsActionedTotal?: number
checksRun: string[] | string
checksTriggered: string[] | string
checksFromCache: string[] | string
actionsRun: string[] | string
rulesRun: string[] | string
rulesCachedTotal: number
rulesTriggered: string[] | string
}

View File

@@ -99,15 +99,16 @@ export interface AttributionCriteria {
* This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`
*
* * If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)
* * If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or reddit image/video (i.redd.it / v.redd.it)
* * If `link` is included then aggregate author's submission history which is external links but not media
* * If `redditMedia` is included then aggregate on author's submissions history which are media hosted on reddit: galleries, videos, and images (i.redd.it / v.redd.it)
* * If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or domain is `reddit.com`
* * If `link` is included then aggregate author's submission history which is external links and not recognized as `media` by reddit
*
* If nothing is specified or list is empty (default) all domains are aggregated
* If nothing is specified or list is empty (default) rule will only aggregate on `link` and `media` (ignores reddit-hosted content and self-posts)
*
* @default undefined
* @examples [[]]
* */
aggregateOn?: ('media' | 'self' | 'link')[],
aggregateOn?: ('media' | 'redditMedia' | 'self' | 'link')[],
/**
* Should the criteria consolidate recognized media domains into the parent domain?
@@ -173,7 +174,7 @@ export class AttributionRule extends Rule {
window,
thresholdOn = 'all',
minActivityCount = 10,
aggregateOn = [],
aggregateOn = ['link','media'],
consolidateMediaDomains = false,
domains = [],
domainsCombined = false,
@@ -232,16 +233,23 @@ export class AttributionRule extends Rule {
const domainInfo = getAttributionIdentifier(sub, consolidateMediaDomains)
let domainType = 'link';
if(sub.secure_media !== undefined && sub.secure_media !== null) {
domainType = 'media';
} else if((sub.is_self || sub.is_video || sub.domain === 'i.redd.it')) {
if(sub.is_video || ['i.redd.it','v.redd.it'].includes(sub.domain)
// @ts-ignore
|| sub.gallery_data !== undefined) {
domainType = 'redditMedia';
} else if(sub.is_self || sub.domain === 'reddit.com') {
domainType = 'self';
} else if(sub.secure_media !== undefined && sub.secure_media !== null) {
domainType = 'media';
}
if(aggregateOn.length !== 0) {
if(domainType === 'media' && !aggregateOn.includes('media')) {
return acc;
}
if(domainType === 'redditMedia' && !aggregateOn.includes('redditMedia')) {
return acc;
}
if(domainType === 'self' && !aggregateOn.includes('self')) {
return acc;
}
@@ -384,7 +392,7 @@ export class AttributionRule extends Rule {
}
interface AttributionConfig extends ReferenceSubmission {
interface AttributionConfig {
/**
* A list threshold-window values to test attribution against

View File

@@ -1,21 +1,32 @@
import {Rule, RuleJSONConfig, RuleOptions, RulePremise, RuleResult} from "./index";
import {Comment, VoteableContent} from "snoowrap";
import Submission from "snoowrap/dist/objects/Submission";
// @ts-ignore
import subImageMatch from 'matches-subimage';
import {
activityWindowText, asSubmission,
comparisonTextOp, FAIL, formatNumber, getActivitySubredditName, isSubmission, objectToStringSummary,
parseGenericValueOrPercentComparison, parseStringToRegex, parseSubredditName,
activityWindowText,
asSubmission, compareImages,
comparisonTextOp,
FAIL,
formatNumber,
getActivitySubredditName, getImageDataFromUrl,
isSubmission,
isValidImageURL,
objectToStringSummary,
parseGenericValueOrPercentComparison,
parseStringToRegex,
parseSubredditName,
parseUsableLinkIdentifier,
PASS, toStrongSubredditState
PASS,
toStrongSubredditState
} from "../util";
import {
ActivityWindow,
ActivityWindowCriteria,
ActivityWindowType, CommentState,
ActivityWindowType, CommentState, ImageDetection,
ReferenceSubmission, StrongSubredditState, SubmissionState,
SubredditCriteria, SubredditState
} from "../Common/interfaces";
import {SubredditResources} from "../Subreddit/SubredditResources";
const parseLink = parseUsableLinkIdentifier();
@@ -23,6 +34,7 @@ export class RecentActivityRule extends Rule {
window: ActivityWindowType;
thresholds: ActivityThreshold[];
useSubmissionAsReference: boolean;
imageDetection: Required<ImageDetection>
lookAt?: 'comments' | 'submissions';
constructor(options: RecentActivityRuleOptions) {
@@ -30,8 +42,21 @@ export class RecentActivityRule extends Rule {
const {
window = 15,
useSubmissionAsReference = true,
imageDetection,
lookAt,
} = options || {};
const {
enable = false,
fetchBehavior = 'extension',
threshold = 5
} = imageDetection || {};
this.imageDetection = {
enable,
fetchBehavior,
threshold
};
this.lookAt = lookAt;
this.useSubmissionAsReference = useSubmissionAsReference;
this.window = window;
@@ -73,16 +98,56 @@ export class RecentActivityRule extends Rule {
} else if (item.is_self) {
this.logger.warn('Cannot use post as reference because triggered Submission is not a link type');
} else {
const usableUrl = parseLink(await item.url);
viableActivity = viableActivity.filter((x) => {
if (!asSubmission(x)) {
return false;
const itemId = item.id;
const referenceUrl = await item.url;
const usableUrl = parseLink(referenceUrl);
const filteredActivity = [];
let referenceImage;
if(this.imageDetection.enable) {
const [response, imgData, reason] = await getImageDataFromUrl(referenceUrl);
if(reason !== undefined) {
this.logger.verbose(reason);
} else {
referenceImage = imgData
}
}
let longRun;
if(referenceImage !== undefined) {
const l = this.logger;
longRun = setTimeout(() => {
l.verbose('FYI: Image processing is causing rule to take longer than normal');
}, 2500);
}
for(const x of viableActivity) {
if (!asSubmission(x) || x.id === itemId) {
continue;
}
if (x.url === undefined) {
return false;
continue;
}
return parseLink(x.url) === usableUrl;
});
if(parseLink(x.url) === usableUrl) {
filteredActivity.push(x);
}
// only do image detection if regular URL comparison and other conditions fail first
// to reduce CPU/bandwidth usage
if(referenceImage !== undefined) {
const [response, imgData, reason] = await getImageDataFromUrl(x.url);
if(imgData !== undefined) {
try {
const [compareResult, sameImage] = await compareImages(referenceImage, imgData, this.imageDetection.threshold);
if(sameImage) {
filteredActivity.push(x);
}
} catch (err) {
this.logger.warn(`Unexpected error encountered while comparing images, will skip comparison: ${err.message}`);
}
}
}
}
if(longRun !== undefined) {
clearTimeout(longRun);
}
viableActivity = filteredActivity;
}
}
@@ -288,6 +353,8 @@ interface RecentActivityConfig extends ActivityWindow, ReferenceSubmission {
* @minItems 1
* */
thresholds: ActivityThreshold[],
imageDetection?: ImageDetection
}
export interface RecentActivityRuleOptions extends RecentActivityConfig, RuleOptions {

View File

@@ -28,6 +28,7 @@ interface ResultContext {
export interface RuleResult extends ResultContext {
premise: RulePremise
kind: string
name: string
triggered: (boolean | null)
}
@@ -153,6 +154,7 @@ export abstract class Rule implements IRule, Triggerable {
protected getResult(triggered: (boolean | null) = null, context: ResultContext = {}): RuleResult {
return {
premise: this.getPremise(),
kind: this.getKind(),
name: this.name,
triggered,
...context,

View File

@@ -69,6 +69,10 @@
},
"type": "array"
},
"shadowBanned": {
"description": "Is the author shadowbanned?\n\nThis is determined by trying to retrieve the author's profile. If a 404 is returned it is likely they are shadowbanned",
"type": "boolean"
},
"totalKarma": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",

View File

@@ -233,7 +233,7 @@
"properties": {
"aggregateOn": {
"default": "undefined",
"description": "This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or reddit image/video (i.redd.it / v.redd.it)\n* If `link` is included then aggregate author's submission history which is external links but not media\n\nIf nothing is specified or list is empty (default) all domains are aggregated",
"description": "This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `redditMedia` is included then aggregate on author's submissions history which are media hosted on reddit: galleries, videos, and images (i.redd.it / v.redd.it)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or domain is `reddit.com`\n* If `link` is included then aggregate author's submission history which is external links and not recognized as `media` by reddit\n\nIf nothing is specified or list is empty (default) rule will only aggregate on `link` and `media` (ignores reddit-hosted content and self-posts)",
"examples": [
[
]
@@ -242,6 +242,7 @@
"enum": [
"link",
"media",
"redditMedia",
"self"
],
"type": "string"
@@ -418,11 +419,6 @@
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"useSubmissionAsReference": {
"default": true,
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
"type": "boolean"
}
},
"required": [
@@ -498,6 +494,10 @@
},
"type": "array"
},
"shadowBanned": {
"description": "Is the author shadowbanned?\n\nThis is determined by trying to retrieve the author's profile. If a 404 is returned it is likely they are shadowbanned",
"type": "boolean"
},
"totalKarma": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -1604,6 +1604,31 @@
],
"type": "object"
},
"ImageDetection": {
"description": "When comparing submissions detect if the reference submission is an image and do a pixel-comparison to other detected image submissions.\n\n**Note:** This is an **experimental feature**",
"properties": {
"enable": {
"description": "Is image detection enabled?",
"type": "boolean"
},
"fetchBehavior": {
"default": "extension",
"description": "Determines how and when to check if a URL is an image\n\n**Note:** After fetching a URL the **Content-Type** is validated to contain `image` before detection occurs\n\n**When `extension`:** (default)\n\n* Only URLs that end in known image extensions (.png, .jpg, etc...) are fetched\n\n**When `unknown`:**\n\n* URLs that end in known image extensions (.png, .jpg, etc...) are fetched\n* URLs with no extension or unknown (IE non-video, non-doc, etc...) are fetched\n\n**When `all`:**\n\n* All submissions that have URLs (non-self) will be fetched, regardless of extension\n* **Note:** This can be bandwidth/CPU intensive if history window is large so use with care",
"enum": [
"all",
"extension",
"unknown"
],
"type": "string"
},
"threshold": {
"default": 5,
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.\n\nDefault is `5`",
"type": "number"
}
},
"type": "object"
},
"LockActionJson": {
"description": "Lock the Activity",
"properties": {
@@ -1929,6 +1954,10 @@
}
]
},
"imageDetection": {
"$ref": "#/definitions/ImageDetection",
"description": "When comparing submissions detect if the reference submission is an image and do a pixel-comparison to other detected image submissions.\n\n**Note:** This is an **experimental feature**"
},
"itemIs": {
"anyOf": [
{

View File

@@ -179,7 +179,7 @@
"properties": {
"aggregateOn": {
"default": "undefined",
"description": "This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or reddit image/video (i.redd.it / v.redd.it)\n* If `link` is included then aggregate author's submission history which is external links but not media\n\nIf nothing is specified or list is empty (default) all domains are aggregated",
"description": "This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `redditMedia` is included then aggregate on author's submissions history which are media hosted on reddit: galleries, videos, and images (i.redd.it / v.redd.it)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or domain is `reddit.com`\n* If `link` is included then aggregate author's submission history which is external links and not recognized as `media` by reddit\n\nIf nothing is specified or list is empty (default) rule will only aggregate on `link` and `media` (ignores reddit-hosted content and self-posts)",
"examples": [
[
]
@@ -188,6 +188,7 @@
"enum": [
"link",
"media",
"redditMedia",
"self"
],
"type": "string"
@@ -364,11 +365,6 @@
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"useSubmissionAsReference": {
"default": true,
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
"type": "boolean"
}
},
"required": [
@@ -444,6 +440,10 @@
},
"type": "array"
},
"shadowBanned": {
"description": "Is the author shadowbanned?\n\nThis is determined by trying to retrieve the author's profile. If a 404 is returned it is likely they are shadowbanned",
"type": "boolean"
},
"totalKarma": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -863,6 +863,31 @@
],
"type": "object"
},
"ImageDetection": {
"description": "When comparing submissions detect if the reference submission is an image and do a pixel-comparison to other detected image submissions.\n\n**Note:** This is an **experimental feature**",
"properties": {
"enable": {
"description": "Is image detection enabled?",
"type": "boolean"
},
"fetchBehavior": {
"default": "extension",
"description": "Determines how and when to check if a URL is an image\n\n**Note:** After fetching a URL the **Content-Type** is validated to contain `image` before detection occurs\n\n**When `extension`:** (default)\n\n* Only URLs that end in known image extensions (.png, .jpg, etc...) are fetched\n\n**When `unknown`:**\n\n* URLs that end in known image extensions (.png, .jpg, etc...) are fetched\n* URLs with no extension or unknown (IE non-video, non-doc, etc...) are fetched\n\n**When `all`:**\n\n* All submissions that have URLs (non-self) will be fetched, regardless of extension\n* **Note:** This can be bandwidth/CPU intensive if history window is large so use with care",
"enum": [
"all",
"extension",
"unknown"
],
"type": "string"
},
"threshold": {
"default": 5,
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.\n\nDefault is `5`",
"type": "number"
}
},
"type": "object"
},
"RecentActivityRuleJSONConfig": {
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"properties": {
@@ -885,6 +910,10 @@
}
]
},
"imageDetection": {
"$ref": "#/definitions/ImageDetection",
"description": "When comparing submissions detect if the reference submission is an image and do a pixel-comparison to other detected image submissions.\n\n**Note:** This is an **experimental feature**"
},
"itemIs": {
"anyOf": [
{

View File

@@ -156,7 +156,7 @@
"properties": {
"aggregateOn": {
"default": "undefined",
"description": "This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or reddit image/video (i.redd.it / v.redd.it)\n* If `link` is included then aggregate author's submission history which is external links but not media\n\nIf nothing is specified or list is empty (default) all domains are aggregated",
"description": "This list determines which categories of domains should be aggregated on. All aggregated domains will be tested against `threshold`\n\n* If `media` is included then aggregate author's submission history which reddit recognizes as media (youtube, vimeo, etc.)\n* If `redditMedia` is included then aggregate on author's submissions history which are media hosted on reddit: galleries, videos, and images (i.redd.it / v.redd.it)\n* If `self` is included then aggregate on author's submission history which are self-post (`self.[subreddit]`) or domain is `reddit.com`\n* If `link` is included then aggregate author's submission history which is external links and not recognized as `media` by reddit\n\nIf nothing is specified or list is empty (default) rule will only aggregate on `link` and `media` (ignores reddit-hosted content and self-posts)",
"examples": [
[
]
@@ -165,6 +165,7 @@
"enum": [
"link",
"media",
"redditMedia",
"self"
],
"type": "string"
@@ -341,11 +342,6 @@
],
"pattern": "^[a-zA-Z]([\\w -]*[\\w])?$",
"type": "string"
},
"useSubmissionAsReference": {
"default": true,
"description": "If activity is a Submission and is a link (not self-post) then only look at Submissions that contain this link, otherwise consider all activities.",
"type": "boolean"
}
},
"required": [
@@ -421,6 +417,10 @@
},
"type": "array"
},
"shadowBanned": {
"description": "Is the author shadowbanned?\n\nThis is determined by trying to retrieve the author's profile. If a 404 is returned it is likely they are shadowbanned",
"type": "boolean"
},
"totalKarma": {
"description": "A string containing a comparison operator and a value to compare against\n\nThe syntax is `(< OR > OR <= OR >=) <number>`\n\n* EX `> 100` => greater than 100",
"pattern": "^\\s*(>|>=|<|<=)\\s*(\\d+)\\s*(%?)(.*)$",
@@ -840,6 +840,31 @@
],
"type": "object"
},
"ImageDetection": {
"description": "When comparing submissions detect if the reference submission is an image and do a pixel-comparison to other detected image submissions.\n\n**Note:** This is an **experimental feature**",
"properties": {
"enable": {
"description": "Is image detection enabled?",
"type": "boolean"
},
"fetchBehavior": {
"default": "extension",
"description": "Determines how and when to check if a URL is an image\n\n**Note:** After fetching a URL the **Content-Type** is validated to contain `image` before detection occurs\n\n**When `extension`:** (default)\n\n* Only URLs that end in known image extensions (.png, .jpg, etc...) are fetched\n\n**When `unknown`:**\n\n* URLs that end in known image extensions (.png, .jpg, etc...) are fetched\n* URLs with no extension or unknown (IE non-video, non-doc, etc...) are fetched\n\n**When `all`:**\n\n* All submissions that have URLs (non-self) will be fetched, regardless of extension\n* **Note:** This can be bandwidth/CPU intensive if history window is large so use with care",
"enum": [
"all",
"extension",
"unknown"
],
"type": "string"
},
"threshold": {
"default": 5,
"description": "The percentage, as a whole number, of pixels that are **different** between the two images at which point the images are not considered the same.\n\nDefault is `5`",
"type": "number"
}
},
"type": "object"
},
"RecentActivityRuleJSONConfig": {
"description": "Checks a user's history for any Activity (Submission/Comment) in the subreddits specified in thresholds\n\nAvailable data for [Action templating](https://github.com/FoxxMD/context-mod#action-templating):\n\n```\nsummary => comma-deliminated list of subreddits that hit the threshold and their count EX subredditA(1), subredditB(4),...\nsubCount => Total number of subreddits that hit the threshold\ntotalCount => Total number of all activity occurrences in subreddits\n```",
"properties": {
@@ -862,6 +887,10 @@
}
]
},
"imageDetection": {
"$ref": "#/definitions/ImageDetection",
"description": "When comparing submissions detect if the reference submission is an image and do a pixel-comparison to other detected image submissions.\n\n**Note:** This is an **experimental feature**"
},
"itemIs": {
"anyOf": [
{

View File

@@ -3,7 +3,7 @@ import {Logger} from "winston";
import {SubmissionCheck} from "../Check/SubmissionCheck";
import {CommentCheck} from "../Check/CommentCheck";
import {
cacheStats,
cacheStats, createHistoricalStatsDisplay,
createRetryHandler,
determineNewResults, findLastIndex, formatNumber,
mergeArr, parseFromJsonOrYamlToObject, pollingInfo, resultsSummary, sleep, totalFromMapStats, triggeredIndicator,
@@ -17,7 +17,7 @@ import {
ActionResult,
DEFAULT_POLLING_INTERVAL,
DEFAULT_POLLING_LIMIT, Invokee,
ManagerOptions, ManagerStateChangeOption, PAUSED,
ManagerOptions, ManagerStateChangeOption, ManagerStats, PAUSED,
PollingOptionsStrong, ResourceStats, RUNNING, RunState, STOPPED, SYSTEM, USER
} from "../Common/interfaces";
import Submission from "snoowrap/dist/objects/Submission";
@@ -39,6 +39,7 @@ import {JSONConfig} from "../JsonConfig";
import {CheckStructuredJson} from "../Check";
import NotificationManager from "../Notification/NotificationManager";
import action from "../Web/Server/routes/authenticated/user/action";
import {createHistoricalDefaults, historicalDefaults} from "../Common/defaults";
export interface RunningState {
state: RunState,
@@ -65,39 +66,6 @@ export interface RuntimeManagerOptions extends ManagerOptions {
maxWorkers: number;
}
export interface ManagerStats {
eventsCheckedTotal: number
eventsCheckedSinceStartTotal: number
eventsAvg: number
checksRunTotal: number
checksRunSinceStartTotal: number
checksTriggered: number
checksTriggeredTotal: number
checksTriggeredSinceStart: number
checksTriggeredSinceStartTotal: number
rulesRunTotal: number
rulesRunSinceStartTotal: number
rulesCachedTotal: number
rulesCachedSinceStartTotal: number
rulesTriggeredTotal: number
rulesTriggeredSinceStartTotal: number
rulesAvg: number
actionsRun: number
actionsRunTotal: number
actionsRunSinceStart: number,
actionsRunSinceStartTotal: number
cache: {
provider: string,
currentKeyCount: number,
isShared: boolean,
totalRequests: number,
totalMiss: number,
missPercent: string,
requestRate: number,
types: ResourceStats
},
}
interface QueuedIdentifier {
id: string,
shouldRefresh: boolean
@@ -164,50 +132,22 @@ export class Manager {
// use by api nanny to slow event consumption
delayBy?: number;
eventsCheckedTotal: number = 0;
eventsCheckedSinceStartTotal: number = 0;
eventsSample: number[] = [];
eventsSampleInterval: any;
eventsRollingAvg: number = 0;
checksRunTotal: number = 0;
checksRunSinceStartTotal: number = 0;
checksTriggered: Map<string, number> = new Map();
checksTriggeredSinceStart: Map<string, number> = new Map();
rulesRunTotal: number = 0;
rulesRunSinceStartTotal: number = 0;
rulesCachedTotal: number = 0;
rulesCachedSinceStartTotal: number = 0;
rulesTriggeredTotal: number = 0;
rulesTriggeredSinceStartTotal: number = 0;
rulesUniqueSample: number[] = [];
rulesUniqueSampleInterval: any;
rulesUniqueRollingAvg: number = 0;
actionsRun: Map<string, number> = new Map();
actionsRunSinceStart: Map<string, number> = new Map();
actionedEvents: ActionedEvent[] = [];
getStats = async (): Promise<ManagerStats> => {
const data: any = {
eventsCheckedTotal: this.eventsCheckedTotal,
eventsCheckedSinceStartTotal: this.eventsCheckedSinceStartTotal,
eventsAvg: formatNumber(this.eventsRollingAvg),
checksRunTotal: this.checksRunTotal,
checksRunSinceStartTotal: this.checksRunSinceStartTotal,
checksTriggered: this.checksTriggered,
checksTriggeredTotal: totalFromMapStats(this.checksTriggered),
checksTriggeredSinceStart: this.checksTriggeredSinceStart,
checksTriggeredSinceStartTotal: totalFromMapStats(this.checksTriggeredSinceStart),
rulesRunTotal: this.rulesRunTotal,
rulesRunSinceStartTotal: this.rulesRunSinceStartTotal,
rulesCachedTotal: this.rulesCachedTotal,
rulesCachedSinceStartTotal: this.rulesCachedSinceStartTotal,
rulesTriggeredTotal: this.rulesTriggeredTotal,
rulesTriggeredSinceStartTotal: this.rulesTriggeredSinceStartTotal,
rulesAvg: formatNumber(this.rulesUniqueRollingAvg),
actionsRun: this.actionsRun,
actionsRunTotal: totalFromMapStats(this.actionsRun),
actionsRunSinceStart: this.actionsRunSinceStart,
actionsRunSinceStartTotal: totalFromMapStats(this.actionsRunSinceStart),
historical: {
lastReload: createHistoricalStatsDisplay(createHistoricalDefaults()),
allTime: createHistoricalStatsDisplay(createHistoricalDefaults()),
},
cache: {
provider: 'none',
currentKeyCount: 0,
@@ -223,6 +163,7 @@ export class Manager {
if (this.resources !== undefined) {
const resStats = await this.resources.getStats();
data.historical = this.resources.getHistoricalDisplayStats();
data.cache = resStats.cache;
data.cache.currentKeyCount = await this.resources.getCacheKeyCount();
data.cache.isShared = this.resources.cacheSettingsHash === 'default';
@@ -270,8 +211,9 @@ export class Manager {
this.eventsSampleInterval = setInterval((function(self) {
return function() {
const et = self.resources !== undefined ? self.resources.stats.historical.allTime.eventsCheckedTotal : 0;
const rollingSample = self.eventsSample.slice(0, 7)
rollingSample.unshift(self.eventsCheckedTotal)
rollingSample.unshift(et)
self.eventsSample = rollingSample;
const diff = self.eventsSample.reduceRight((acc: number[], curr, index) => {
if(self.eventsSample[index + 1] !== undefined) {
@@ -291,7 +233,8 @@ export class Manager {
this.rulesUniqueSampleInterval = setInterval((function(self) {
return function() {
const rollingSample = self.rulesUniqueSample.slice(0, 7)
rollingSample.unshift(self.rulesRunTotal - self.rulesCachedTotal);
const rt = self.resources !== undefined ? self.resources.stats.historical.allTime.rulesRunTotal - self.resources.stats.historical.allTime.rulesCachedTotal : 0;
rollingSample.unshift(rt);
self.rulesUniqueSample = rollingSample;
const diff = self.rulesUniqueSample.reduceRight((acc: number[], curr, index) => {
if(self.rulesUniqueSample[index + 1] !== undefined) {
@@ -387,7 +330,7 @@ export class Manager {
return q;
}
protected parseConfigurationFromObject(configObj: object) {
protected async parseConfigurationFromObject(configObj: object) {
try {
const configBuilder = new ConfigBuilder({logger: this.logger});
const validJson = configBuilder.validateJson(configObj);
@@ -437,7 +380,7 @@ export class Manager {
caching,
client: this.client,
};
this.resources = this.cacheManager.set(this.subreddit.display_name, resourceConfig);
this.resources = await this.cacheManager.set(this.subreddit.display_name, resourceConfig);
this.resources.setLogger(this.logger);
this.logger.info('Subreddit-specific options updated');
@@ -532,7 +475,7 @@ export class Manager {
throw new ConfigParseError('Could not parse wiki page contents as JSON or YAML')
}
this.parseConfigurationFromObject(configObj);
await this.parseConfigurationFromObject(configObj);
this.logger.info('Checks updated');
if(!suppressNotification) {
@@ -549,8 +492,6 @@ export class Manager {
async runChecks(checkType: ('Comment' | 'Submission'), activity: (Submission | Comment), options?: runCheckOptions): Promise<void> {
const checks = checkType === 'Comment' ? this.commentChecks : this.submissionChecks;
let item = activity;
this.eventsCheckedTotal++;
this.eventsCheckedSinceStartTotal++;
const itemId = await item.id;
let allRuleResults: RuleResult[] = [];
const itemIdentifier = `${checkType === 'Submission' ? 'SUB' : 'COM'} ${itemId}`;
@@ -622,7 +563,9 @@ export class Manager {
actionResults: [],
}
let triggered = false;
let triggeredCheckName;
const checksRunNames = [];
const cachedCheckNames = [];
try {
for (const check of checks) {
if (checkNames.length > 0 && !checkNames.map(x => x.toLowerCase()).some(x => x === check.name.toLowerCase())) {
@@ -633,6 +576,7 @@ export class Manager {
this.logger.info(`Check ${check.name} not run because it is not enabled, skipping...`);
continue;
}
checksRunNames.push(check.name);
checksRun++;
triggered = false;
let isFromCache = false;
@@ -642,6 +586,8 @@ export class Manager {
isFromCache = fromCache;
if(!fromCache) {
await check.setCacheResult(item, {result: checkTriggered, ruleResults: checkResults});
} else {
cachedCheckNames.push(check.name);
}
currentResults = checkResults;
totalRulesRun += checkResults.length;
@@ -658,6 +604,7 @@ export class Manager {
}
if (triggered) {
triggeredCheckName = check.name;
actionedEvent.check = check.name;
actionedEvent.ruleResults = currentResults;
if(isFromCache) {
@@ -665,8 +612,6 @@ export class Manager {
} else {
actionedEvent.ruleSummary = resultsSummary(currentResults, check.condition);
}
this.checksTriggered.set(check.name, (this.checksTriggered.get(check.name) || 0) + 1);
this.checksTriggeredSinceStart.set(check.name, (this.checksTriggeredSinceStart.get(check.name) || 0) + 1);
runActions = await check.runActions(item, currentResults.filter(x => x.triggered), dryRun);
actionsRun = runActions.length;
@@ -688,23 +633,6 @@ export class Manager {
}
} finally {
try {
const cachedTotal = totalRulesRun - allRuleResults.length;
const triggeredRulesTotal = allRuleResults.filter(x => x.triggered).length;
this.checksRunTotal += checksRun;
this.checksRunSinceStartTotal += checksRun;
this.rulesRunTotal += totalRulesRun;
this.rulesRunSinceStartTotal += totalRulesRun;
this.rulesCachedTotal += cachedTotal;
this.rulesCachedSinceStartTotal += cachedTotal;
this.rulesTriggeredTotal += triggeredRulesTotal;
this.rulesTriggeredSinceStartTotal += triggeredRulesTotal;
for (const a of runActions) {
const name = a.name;
this.actionsRun.set(name, (this.actionsRun.get(name) || 0) + 1);
this.actionsRunSinceStart.set(name, (this.actionsRunSinceStart.get(name) || 0) + 1);
}
actionedEvent.actionResults = runActions;
if(triggered) {
await this.resources.addActionedEvent(actionedEvent);
@@ -715,6 +643,18 @@ export class Manager {
this.currentLabels = [];
} catch (err) {
this.logger.error('Error occurred while cleaning up Activity check and generating stats', err);
} finally {
this.resources.updateHistoricalStats({
eventsCheckedTotal: 1,
eventsActionedTotal: triggered ? 1 : 0,
checksTriggered: triggeredCheckName !== undefined ? [triggeredCheckName] : [],
checksRun: checksRunNames,
checksFromCache: cachedCheckNames,
actionsRun: runActions.map(x => x.name),
rulesRun: allRuleResults.map(x => x.name),
rulesTriggered: allRuleResults.filter(x => x.triggered).map(x => x.name),
rulesCachedTotal: totalRulesRun - allRuleResults.length,
});
}
}
}
@@ -1002,13 +942,6 @@ export class Manager {
stream.removeListener('item', v);
}
this.startedAt = undefined;
this.eventsCheckedSinceStartTotal = 0;
this.checksRunSinceStartTotal = 0;
this.rulesRunSinceStartTotal = 0;
this.rulesCachedSinceStartTotal = 0;
this.rulesTriggeredSinceStartTotal = 0;
this.checksTriggeredSinceStart = new Map();
this.actionsRunSinceStart = new Map();
this.logger.info(`Events STOPPED by ${causedBy}`);
this.eventsState = {
state: STOPPED,

View File

@@ -13,7 +13,7 @@ import fetch from 'node-fetch';
import {
asSubmission,
buildCacheOptionsFromProvider, buildCachePrefix,
cacheStats, comparisonTextOp, createCacheManager,
cacheStats, comparisonTextOp, createCacheManager, createHistoricalStatsDisplay,
formatNumber, getActivityAuthorName, getActivitySubredditName, isStrongSubredditState,
mergeArr,
parseExternalUrl, parseGenericValueComparison,
@@ -22,9 +22,21 @@ import {
import LoggedError from "../Utils/LoggedError";
import {
BotInstanceConfig,
CacheOptions, CommentState,
Footer, OperatorConfig, ResourceStats, StrongCache, SubmissionState,
CacheConfig, TTLConfig, TypedActivityStates, UserResultCache, ActionedEvent, SubredditState, StrongSubredditState
CacheOptions,
CommentState,
Footer,
OperatorConfig,
ResourceStats,
StrongCache,
SubmissionState,
CacheConfig,
TTLConfig,
TypedActivityStates,
UserResultCache,
ActionedEvent,
SubredditState,
StrongSubredditState,
HistoricalStats, HistoricalStatUpdateData, SubredditHistoricalStats, SubredditHistoricalStatsDisplay
} from "../Common/interfaces";
import UserNotes from "./UserNotes";
import Mustache from "mustache";
@@ -33,7 +45,7 @@ import {AuthorCriteria} from "../Author/Author";
import {SPoll} from "./Streams";
import {Cache} from 'cache-manager';
import {Submission, Comment} from "snoowrap/dist/objects";
import {cacheTTLDefaults} from "../Common/defaults";
import {cacheTTLDefaults, createHistoricalDefaults, historicalDefaults} from "../Common/defaults";
import {check} from "tcp-port-used";
export const DEFAULT_FOOTER = '\r\n*****\r\nThis action was performed by [a bot.]({{botLink}}) Mention a moderator or [send a modmail]({{modmailLink}}) if you any ideas, questions, or concerns about this action.';
@@ -79,10 +91,14 @@ export class SubredditResources {
cacheType: string
cacheSettingsHash?: string;
pruneInterval?: any;
historicalSaveInterval?: any;
prefix?: string
actionedEventsMax: number;
stats: { cache: ResourceStats };
stats: {
cache: ResourceStats
historical: SubredditHistoricalStats
};
constructor(name: string, options: SubredditResourceOptions) {
const {
@@ -127,7 +143,11 @@ export class SubredditResources {
}
this.stats = {
cache: cacheStats()
cache: cacheStats(),
historical: {
allTime: createHistoricalDefaults(),
lastReload: createHistoricalDefaults()
}
};
const cacheUseCB = (miss: boolean) => {
@@ -151,6 +171,92 @@ export class SubredditResources {
}
}
async initHistoricalStats() {
const at = await this.cache.wrap(`${this.name}-historical-allTime`, () => createHistoricalDefaults(), {ttl: 0}) as object;
const rehydratedAt: any = {};
for(const [k, v] of Object.entries(at)) {
if(Array.isArray(v)) {
rehydratedAt[k] = new Map(v);
} else {
rehydratedAt[k] = v;
}
}
this.stats.historical.allTime = rehydratedAt as HistoricalStats;
// const lr = await this.cache.wrap(`${this.name}-historical-lastReload`, () => createHistoricalDefaults(), {ttl: 0}) as object;
// const rehydratedLr: any = {};
// for(const [k, v] of Object.entries(lr)) {
// if(Array.isArray(v)) {
// rehydratedLr[k] = new Map(v);
// } else {
// rehydratedLr[k] = v;
// }
// }
// this.stats.historical.lastReload = rehydratedLr;
}
updateHistoricalStats(data: HistoricalStatUpdateData) {
for(const [k, v] of Object.entries(data)) {
if(this.stats.historical.lastReload[k] !== undefined) {
if(typeof v === 'number') {
this.stats.historical.lastReload[k] += v;
} else if(this.stats.historical.lastReload[k] instanceof Map) {
const keys = Array.isArray(v) ? v : [v];
for(const key of keys) {
this.stats.historical.lastReload[k].set(key, (this.stats.historical.lastReload[k].get(key) || 0) + 1);
}
}
}
if(this.stats.historical.allTime[k] !== undefined) {
if(typeof v === 'number') {
this.stats.historical.allTime[k] += v;
} else if(this.stats.historical.allTime[k] instanceof Map) {
const keys = Array.isArray(v) ? v : [v];
for(const key of keys) {
this.stats.historical.allTime[k].set(key, (this.stats.historical.allTime[k].get(key) || 0) + 1);
}
}
}
}
}
getHistoricalDisplayStats(): SubredditHistoricalStatsDisplay {
return {
allTime: createHistoricalStatsDisplay(this.stats.historical.allTime),
lastReload: createHistoricalStatsDisplay(this.stats.historical.lastReload)
}
}
async saveHistoricalStats() {
const atSerializable: any = {};
for(const [k, v] of Object.entries(this.stats.historical.allTime)) {
if(v instanceof Map) {
atSerializable[k] = Array.from(v.entries());
} else {
atSerializable[k] = v;
}
}
await this.cache.set(`${this.name}-historical-allTime`, atSerializable, {ttl: 0});
// const lrSerializable: any = {};
// for(const [k, v] of Object.entries(this.stats.historical.lastReload)) {
// if(v instanceof Map) {
// lrSerializable[k] = Array.from(v.entries());
// } else {
// lrSerializable[k] = v;
// }
// }
// await this.cache.set(`${this.name}-historical-lastReload`, lrSerializable, {ttl: 0});
}
setHistoricalSaveInterval() {
this.historicalSaveInterval = setInterval((function(self) {
return async () => {
await self.saveHistoricalStats();
}
})(this),10000);
}
async getCacheKeyCount() {
if (this.cache.store.keys !== undefined) {
if(this.cacheType === 'redis') {
@@ -791,7 +897,7 @@ export class BotResourcesManager {
return undefined;
}
set(subName: string, initOptions: SubredditResourceConfig): SubredditResources {
async set(subName: string, initOptions: SubredditResourceConfig): Promise<SubredditResources> {
let hash = 'default';
const { caching, ...init } = initOptions;
@@ -841,6 +947,8 @@ export class BotResourcesManager {
res.cache.reset();
}
resource = new SubredditResources(subName, opts);
await resource.initHistoricalStats();
resource.setHistoricalSaveInterval();
this.resources.set(subName, resource);
} else {
// just set non-cache related settings
@@ -851,6 +959,7 @@ export class BotResourcesManager {
// reset cache stats when configuration is reloaded
resource.stats.cache = cacheStats();
}
resource.stats.historical.lastReload = createHistoricalDefaults();
return resource;
}

View File

@@ -14,8 +14,8 @@ import {
} from "../Common/interfaces";
import {
compareDurationValue,
comparisonTextOp,
isActivityWindowCriteria,
comparisonTextOp, getActivityAuthorName,
isActivityWindowCriteria, isStatusError,
normalizeName,
parseDuration,
parseDurationComparison,
@@ -125,17 +125,25 @@ export async function getAuthorActivities(user: RedditUser, options: AuthorTyped
let items: Array<Submission | Comment> = [];
//let count = 1;
let listing;
switch (options.type) {
case 'comment':
listing = await user.getComments({limit: chunkSize});
break;
case 'submission':
listing = await user.getSubmissions({limit: chunkSize});
break;
default:
listing = await user.getOverview({limit: chunkSize});
break;
let listing = [];
try {
switch (options.type) {
case 'comment':
listing = await user.getComments({limit: chunkSize});
break;
case 'submission':
listing = await user.getSubmissions({limit: chunkSize});
break;
default:
listing = await user.getOverview({limit: chunkSize});
break;
}
} catch (err) {
if(isStatusError(err) && err.statusCode === 404) {
throw new SimpleError('Reddit returned a 404 for user history. Likely this user is shadowbanned.');
} else {
throw err;
}
}
let hitEnd = false;
let offset = chunkSize;
@@ -317,179 +325,211 @@ export const renderContent = async (template: string, data: (Submission | Commen
}
export const testAuthorCriteria = async (item: (Comment | Submission), authorOpts: AuthorCriteria, include = true, userNotes: UserNotes) => {
// @ts-ignore
const author: RedditUser = await item.author;
for (const k of Object.keys(authorOpts)) {
// @ts-ignore
if (authorOpts[k] !== undefined) {
switch (k) {
case 'name':
const authPass = () => {
// @ts-ignore
for (const n of authorOpts[k]) {
if (n.toLowerCase() === author.name.toLowerCase()) {
return true;
}
}
return false;
}
const authResult = authPass();
if ((include && !authResult) || (!include && authResult)) {
return false;
}
break;
case 'flairCssClass':
const css = await item.author_flair_css_class;
const cssPass = () => {
// @ts-ignore
for (const c of authorOpts[k]) {
if (c === css) {
return;
}
}
return false;
}
const cssResult = cssPass();
if ((include && !cssResult) || (!include && cssResult)) {
return false;
}
break;
case 'flairText':
const text = await item.author_flair_text;
const textPass = () => {
// @ts-ignore
for (const c of authorOpts[k]) {
if (c === text) {
return
}
}
return false;
};
const textResult = textPass();
if ((include && !textResult) || (!include && textResult)) {
return false;
}
break;
case 'isMod':
const mods: RedditUser[] = await item.subreddit.getModerators();
const isModerator = mods.some(x => x.name === item.author.name);
const modMatch = authorOpts.isMod === isModerator;
if ((include && !modMatch) || (!include && modMatch)) {
return false;
}
break;
case 'age':
const ageTest = compareDurationValue(parseDurationComparison(await authorOpts.age as string), dayjs.unix(await item.author.created));
if ((include && !ageTest) || (!include && ageTest)) {
return false;
}
break;
case 'linkKarma':
const lkCompare = parseGenericValueOrPercentComparison(await authorOpts.linkKarma as string);
let lkMatch;
if (lkCompare.isPercent) {
// @ts-ignore
const tk = author.total_karma as number;
lkMatch = comparisonTextOp(author.link_karma / tk, lkCompare.operator, lkCompare.value / 100);
} else {
lkMatch = comparisonTextOp(author.link_karma, lkCompare.operator, lkCompare.value);
}
if ((include && !lkMatch) || (!include && lkMatch)) {
return false;
}
break;
case 'commentKarma':
const ckCompare = parseGenericValueOrPercentComparison(await authorOpts.commentKarma as string);
let ckMatch;
if (ckCompare.isPercent) {
// @ts-ignore
const ck = author.total_karma as number;
ckMatch = comparisonTextOp(author.comment_karma / ck, ckCompare.operator, ckCompare.value / 100);
} else {
ckMatch = comparisonTextOp(author.comment_karma, ckCompare.operator, ckCompare.value);
}
if ((include && !ckMatch) || (!include && ckMatch)) {
return false;
}
break;
case 'totalKarma':
const tkCompare = parseGenericValueComparison(await authorOpts.totalKarma as string);
if (tkCompare.isPercent) {
throw new SimpleError(`'totalKarma' value on AuthorCriteria cannot be a percentage`);
}
// @ts-ignore
const totalKarma = author.total_karma as number;
const tkMatch = comparisonTextOp(totalKarma, tkCompare.operator, tkCompare.value);
if ((include && !tkMatch) || (!include && tkMatch)) {
return false;
}
break;
case 'verified':
const vMatch = await author.has_verified_mail === authorOpts.verified as boolean;
if ((include && !vMatch) || (!include && vMatch)) {
return false;
}
break;
case 'userNotes':
const notes = await userNotes.getUserNotes(item.author);
const notePass = () => {
for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) {
const {count = '>= 1', search = 'current', type} = noteCriteria;
const {
value,
operator,
isPercent,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const order = extra.includes('asc') ? 'ascending' : 'descending';
switch (search) {
case 'current':
if (notes.length > 0 && notes[notes.length - 1].noteType === type) {
return true;
}
break;
case 'consecutive':
let orderedNotes = notes;
if (order === 'descending') {
orderedNotes = [...notes];
orderedNotes.reverse();
}
let currCount = 0;
for (const note of orderedNotes) {
if (note.noteType === type) {
currCount++;
} else {
currCount = 0;
}
if (isPercent) {
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
}
if (comparisonTextOp(currCount, operator, value)) {
return true;
}
}
break;
case 'total':
if (isPercent) {
if (comparisonTextOp(notes.filter(x => x.noteType === type).length / notes.length, operator, value / 100)) {
return true;
}
} else if (comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
return true;
}
}
}
return false;
}
const noteResult = notePass();
if ((include && !noteResult) || (!include && noteResult)) {
return false;
}
break;
const {shadowBanned, ...rest} = authorOpts;
if(shadowBanned !== undefined) {
try {
// @ts-ignore
await item.author.fetch();
// user is not shadowbanned
// if criteria specifies they SHOULD be shadowbanned then return false now
if(shadowBanned) {
return false;
}
} catch (err) {
if(isStatusError(err) && err.statusCode === 404) {
// user is shadowbanned
// if criteria specifies they should not be shadowbanned then return false now
if(!shadowBanned) {
return false;
}
} else {
throw err;
}
}
}
return true;
try {
const authorName = getActivityAuthorName(item.author);
for (const k of Object.keys(rest)) {
// @ts-ignore
if (authorOpts[k] !== undefined) {
switch (k) {
case 'name':
const authPass = () => {
// @ts-ignore
for (const n of authorOpts[k]) {
if (n.toLowerCase() === authorName.toLowerCase()) {
return true;
}
}
return false;
}
const authResult = authPass();
if ((include && !authResult) || (!include && authResult)) {
return false;
}
break;
case 'flairCssClass':
const css = await item.author_flair_css_class;
const cssPass = () => {
// @ts-ignore
for (const c of authorOpts[k]) {
if (c === css) {
return;
}
}
return false;
}
const cssResult = cssPass();
if ((include && !cssResult) || (!include && cssResult)) {
return false;
}
break;
case 'flairText':
const text = await item.author_flair_text;
const textPass = () => {
// @ts-ignore
for (const c of authorOpts[k]) {
if (c === text) {
return
}
}
return false;
};
const textResult = textPass();
if ((include && !textResult) || (!include && textResult)) {
return false;
}
break;
case 'isMod':
const mods: RedditUser[] = await item.subreddit.getModerators();
const isModerator = mods.some(x => x.name === authorName);
const modMatch = authorOpts.isMod === isModerator;
if ((include && !modMatch) || (!include && modMatch)) {
return false;
}
break;
case 'age':
const ageTest = compareDurationValue(parseDurationComparison(await authorOpts.age as string), dayjs.unix(await item.author.created));
if ((include && !ageTest) || (!include && ageTest)) {
return false;
}
break;
case 'linkKarma':
const lkCompare = parseGenericValueOrPercentComparison(await authorOpts.linkKarma as string);
let lkMatch;
if (lkCompare.isPercent) {
// @ts-ignore
const tk = await item.author.total_karma as number;
lkMatch = comparisonTextOp(item.author.link_karma / tk, lkCompare.operator, lkCompare.value / 100);
} else {
lkMatch = comparisonTextOp(item.author.link_karma, lkCompare.operator, lkCompare.value);
}
if ((include && !lkMatch) || (!include && lkMatch)) {
return false;
}
break;
case 'commentKarma':
const ckCompare = parseGenericValueOrPercentComparison(await authorOpts.commentKarma as string);
let ckMatch;
if (ckCompare.isPercent) {
// @ts-ignore
const ck = await item.author.total_karma as number;
ckMatch = comparisonTextOp(item.author.comment_karma / ck, ckCompare.operator, ckCompare.value / 100);
} else {
ckMatch = comparisonTextOp(item.author.comment_karma, ckCompare.operator, ckCompare.value);
}
if ((include && !ckMatch) || (!include && ckMatch)) {
return false;
}
break;
case 'totalKarma':
const tkCompare = parseGenericValueComparison(await authorOpts.totalKarma as string);
if (tkCompare.isPercent) {
throw new SimpleError(`'totalKarma' value on AuthorCriteria cannot be a percentage`);
}
// @ts-ignore
const totalKarma = await item.author.total_karma as number;
const tkMatch = comparisonTextOp(totalKarma, tkCompare.operator, tkCompare.value);
if ((include && !tkMatch) || (!include && tkMatch)) {
return false;
}
break;
case 'verified':
const vMatch = await item.author.has_verified_mail === authorOpts.verified as boolean;
if ((include && !vMatch) || (!include && vMatch)) {
return false;
}
break;
case 'userNotes':
const notes = await userNotes.getUserNotes(item.author);
const notePass = () => {
for (const noteCriteria of authorOpts[k] as UserNoteCriteria[]) {
const {count = '>= 1', search = 'current', type} = noteCriteria;
const {
value,
operator,
isPercent,
extra = ''
} = parseGenericValueOrPercentComparison(count);
const order = extra.includes('asc') ? 'ascending' : 'descending';
switch (search) {
case 'current':
if (notes.length > 0 && notes[notes.length - 1].noteType === type) {
return true;
}
break;
case 'consecutive':
let orderedNotes = notes;
if (order === 'descending') {
orderedNotes = [...notes];
orderedNotes.reverse();
}
let currCount = 0;
for (const note of orderedNotes) {
if (note.noteType === type) {
currCount++;
} else {
currCount = 0;
}
if (isPercent) {
throw new SimpleError(`When comparing UserNotes with 'consecutive' search 'count' cannot be a percentage. Given: ${count}`);
}
if (comparisonTextOp(currCount, operator, value)) {
return true;
}
}
break;
case 'total':
if (isPercent) {
if (comparisonTextOp(notes.filter(x => x.noteType === type).length / notes.length, operator, value / 100)) {
return true;
}
} else if (comparisonTextOp(notes.filter(x => x.noteType === type).length, operator, value)) {
return true;
}
}
}
return false;
}
const noteResult = notePass();
if ((include && !noteResult) || (!include && noteResult)) {
return false;
}
break;
}
}
}
return true;
} catch (err) {
if(isStatusError(err) && err.statusCode === 404) {
throw new SimpleError('Reddit returned a 404 while trying to retrieve User profile. It is likely this user is shadowbanned.');
} else {
throw err;
}
}
}
export interface ItemContent {

View File

@@ -1,13 +1,10 @@
import {BotStats, BotStatusResponse, SubredditDataResponse} from "./interfaces";
import {ManagerStats, RunningState} from "../../Subreddit/Manager";
import {Invokee, RunState} from "../../Common/interfaces";
import {cacheStats} from "../../util";
import {RunningState} from "../../Subreddit/Manager";
import {Invokee, ManagerStats, RunState} from "../../Common/interfaces";
import {cacheStats, createHistoricalStatsDisplay} from "../../util";
import {createHistoricalDefaults, historicalDefaults} from "../../Common/defaults";
const managerStats: ManagerStats = {
actionsRun: 0,
actionsRunSinceStart: 0,
actionsRunSinceStartTotal: 0,
actionsRunTotal: 0,
cache: {
currentKeyCount: 0,
isShared: false,
@@ -18,22 +15,12 @@ const managerStats: ManagerStats = {
totalRequests: 0,
types: cacheStats()
},
checksRunSinceStartTotal: 0,
checksRunTotal: 0,
checksTriggered: 0,
checksTriggeredSinceStart: 0,
checksTriggeredSinceStartTotal: 0,
checksTriggeredTotal: 0,
historical: {
lastReload: createHistoricalStatsDisplay(createHistoricalDefaults()),
allTime: createHistoricalStatsDisplay(createHistoricalDefaults()),
},
eventsAvg: 0,
eventsCheckedSinceStartTotal: 0,
eventsCheckedTotal: 0,
rulesAvg: 0,
rulesCachedSinceStartTotal: 0,
rulesCachedTotal: 0,
rulesRunSinceStartTotal: 0,
rulesRunTotal: 0,
rulesTriggeredSinceStartTotal: 0,
rulesTriggeredTotal: 0,
};
const botStats: BotStats = {
apiAvg: '-',

View File

@@ -1,4 +1,5 @@
import {ManagerStats, RunningState} from "../../Subreddit/Manager";
import {RunningState} from "../../Subreddit/Manager";
import {ManagerStats} from "../../Common/interfaces";
export interface BotStats {
startedAtHuman: string,

View File

@@ -160,13 +160,19 @@ const status = () => {
submissions: acc.checks.submissions + curr.checks.submissions,
comments: acc.checks.comments + curr.checks.comments,
},
eventsCheckedTotal: acc.eventsCheckedTotal + curr.stats.eventsCheckedTotal,
checksRunTotal: acc.checksRunTotal + curr.stats.checksRunTotal,
checksTriggeredTotal: acc.checksTriggeredTotal + curr.stats.checksTriggeredTotal,
rulesRunTotal: acc.rulesRunTotal + curr.stats.rulesRunTotal,
rulesCachedTotal: acc.rulesCachedTotal + curr.stats.rulesCachedTotal,
rulesTriggeredTotal: acc.rulesTriggeredTotal + curr.stats.rulesTriggeredTotal,
actionsRunTotal: acc.actionsRunTotal + curr.stats.actionsRunTotal,
historical: {
allTime: {
eventsCheckedTotal: acc.historical.allTime.eventsCheckedTotal + curr.stats.historical.allTime.eventsCheckedTotal,
eventsActionedTotal: acc.historical.allTime.eventsActionedTotal + curr.stats.historical.allTime.eventsActionedTotal,
checksRunTotal: acc.historical.allTime.checksRunTotal + curr.stats.historical.allTime.checksRunTotal,
checksFromCacheTotal: acc.historical.allTime.checksFromCacheTotal + curr.stats.historical.allTime.checksFromCacheTotal,
checksTriggeredTotal: acc.historical.allTime.checksTriggeredTotal + curr.stats.historical.allTime.checksTriggeredTotal,
rulesRunTotal: acc.historical.allTime.rulesRunTotal + curr.stats.historical.allTime.rulesRunTotal,
rulesCachedTotal: acc.historical.allTime.rulesCachedTotal + curr.stats.historical.allTime.rulesCachedTotal,
rulesTriggeredTotal: acc.historical.allTime.rulesTriggeredTotal + curr.stats.historical.allTime.rulesTriggeredTotal,
actionsRunTotal: acc.historical.allTime.actionsRunTotal + curr.stats.historical.allTime.actionsRunTotal,
}
},
maxWorkers: acc.maxWorkers + curr.maxWorkers,
subMaxWorkers: acc.subMaxWorkers + curr.subMaxWorkers,
globalMaxWorkers: acc.globalMaxWorkers + curr.globalMaxWorkers,
@@ -178,13 +184,19 @@ const status = () => {
submissions: 0,
comments: 0,
},
eventsCheckedTotal: 0,
checksRunTotal: 0,
checksTriggeredTotal: 0,
rulesRunTotal: 0,
rulesCachedTotal: 0,
rulesTriggeredTotal: 0,
actionsRunTotal: 0,
historical: {
allTime: {
eventsCheckedTotal: 0,
eventsActionedTotal: 0,
checksRunTotal: 0,
checksFromCacheTotal: 0,
checksTriggeredTotal: 0,
rulesRunTotal: 0,
rulesCachedTotal: 0,
rulesTriggeredTotal: 0,
actionsRunTotal: 0,
}
},
maxWorkers: 0,
subMaxWorkers: 0,
globalMaxWorkers: 0,

View File

@@ -333,64 +333,63 @@
<% } %>
<% if (data.name !== 'All') { %>
<div data-subreddit="<%= data.name %>"
class="stats botStats reloadStats">
class="stats botStats reloadStats mb-2">
<label>Events</label>
<span>
<%= data.stats.eventsCheckedSinceStartTotal === undefined ? '-' : data.stats.eventsCheckedSinceStartTotal %>
<%= data.stats.historical.lastReload.eventsCheckedTotal === undefined ? '-' : data.stats.historical.lastReload.eventsCheckedTotal %>
</span>
<label>Checks</label>
<span class="has-tooltip">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span><%= data.stats.checksTriggeredSinceStartTotal %></span> Triggered / <span><%= data.stats.checksRunSinceStartTotal %></span> Run
<span><%= data.stats.historical.lastReload.checksTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.lastReload.checksRunTotal %></span> Run / <span><%= data.stats.historical.lastReload.checksFromCacheTotal %></span> Cached
</span>
<% if (data.name !== 'All') { %>
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>" class="underline" style="text-decoration-style: dotted"><%= data.stats.checksTriggeredSinceStartTotal %> T</a>
<% } else { %>
<%= data.stats.checksTriggeredSinceStartTotal %> T
<% } %>/ <span><%= data.stats.checksRunSinceStartTotal %></span> R
<span class="cursor-help underline" style="text-decoration-style: dotted"><%= data.stats.historical.lastReload.checksTriggeredTotal %> Triggered</span>
</span>
<label>Rules</label>
<span class="has-tooltip">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span><%= data.stats.rulesTriggeredSinceStartTotal %></span> Triggered / <span><%= data.stats.rulesCachedSinceStartTotal %></span> Cached / <span><%= data.stats.rulesRunSinceStartTotal %></span> Run
<span><%= data.stats.historical.lastReload.rulesTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.lastReload.rulesCachedTotal %></span> Cached / <span><%= data.stats.historical.lastReload.rulesRunTotal %></span> Run
</span>
<span class="cursor-help">
<span><%= data.stats.rulesTriggeredSinceStartTotal %></span> T / <span><%= data.stats.rulesCachedSinceStartTotal %></span> C / <span><%= data.stats.rulesRunSinceStartTotal %></span> R</span>
<span class="cursor-help cursor-help underline" style="text-decoration-style: dotted">
<span><%= data.stats.historical.lastReload.rulesTriggeredTotal %></span> Triggered</span>
</span>
<label>Actions</label>
<span class="cursor-help"><%= data.stats.actionsRunSinceStartTotal === undefined ? '-' : data.stats.actionsRunSinceStartTotal %></span>
<span><%= data.stats.historical.lastReload.actionsRunTotal === undefined ? '0' : data.stats.historical.lastReload.actionsRunTotal %> Run</span>
</div>
<% } %>
<div data-subreddit="<%= data.name %>" class="stats botStats allStats">
<div data-subreddit="<%= data.name %>" class="stats botStats allStats mb-2">
<label>Events</label>
<span>
<%= data.stats.eventsCheckedTotal %>
<%= data.stats.historical.allTime.eventsCheckedTotal %>
</span>
<label>Checks</label>
<span class="has-tooltip">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span><%= data.stats.checksTriggeredTotal %></span> Triggered / <span><%= data.stats.checksRunTotal %></span> Run
<span><%= data.stats.historical.allTime.checksTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.allTime.checksRunTotal %></span> Run / <span><%= data.stats.historical.allTime.checksFromCacheTotal %></span> Cached
</span>
<span><%= data.stats.checksTriggeredTotal %> T / <span><%= data.stats.checksRunTotal %></span> R</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><%= data.stats.historical.allTime.checksTriggeredTotal %> Triggered</span>
</span>
<label>Rules</label>
<span class="has-tooltip">
<span class='tooltip rounded shadow-lg p-1 bg-gray-100 text-black -mt-2'>
<span><%= data.stats.rulesTriggeredTotal %></span> Triggered / <span><%= data.stats.rulesCachedTotal %></span> Cached / <span><%= data.stats.rulesRunTotal %></span> Run
<span><%= data.stats.historical.allTime.rulesTriggeredTotal %></span> Triggered / <span><%= data.stats.historical.allTime.rulesCachedTotal %></span> Cached / <span><%= data.stats.historical.allTime.rulesRunTotal %></span> Run
</span>
<span class="cursor-help"><span><%= data.stats.rulesTriggeredTotal %></span> T / <span><%= data.stats.rulesCachedTotal %></span> C / <span><%= data.stats.rulesRunTotal %></span> R</span>
<span class="cursor-help underline" style="text-decoration-style: dotted"><span><%= data.stats.historical.allTime.rulesTriggeredTotal %></span> Triggered</span>
</span>
<label>Actions</label>
<% if (data.name !== 'All') { %>
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>" class="underline" style="text-decoration-style: dotted"><%= data.stats.actionsRunTotal %> Run</a>
<% } else { %>
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>" class="underline" style="text-decoration-style: dotted"><%= data.stats.actionsRunTotal %> Run</a>
<% } %>
<span>
<span><%= data.stats.historical.allTime.actionsRunTotal === undefined ? '0' : data.stats.historical.allTime.actionsRunTotal %> Run</span>
</span>
</div>
<% if (data.name !== 'All') { %>
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>&subreddit=<%= data.name %>" style="text-decoration-style: dotted">Actioned Events</a>
<% } else { %>
<a target="_blank" href="/events?instance=<%= instanceId %>&bot=<%= bot.system.name %>">Actioned Events</a>
<% } %>
</div>
<div>
<div class="text-left pb-2">

View File

@@ -9,11 +9,12 @@ import {InvalidOptionArgumentError} from "commander";
import Submission from "snoowrap/dist/objects/Submission";
import {Comment} from "snoowrap";
import {inflateSync, deflateSync} from "zlib";
import sizeOf from 'image-size';
import {
ActivityWindowCriteria, CacheOptions, CacheProvider,
DurationComparison,
GenericComparison, LogInfo, NamedGroup,
PollingOptionsStrong, RedditEntity, RedditEntityType, RegExResult, ResourceStats,
GenericComparison, HistoricalStats, HistoricalStatsDisplay, ImageData, ImageDetection, LogInfo, NamedGroup,
PollingOptionsStrong, RedditEntity, RedditEntityType, RegExResult, ResembleResult, ResourceStats, StatusCodeError,
StringOperator, StrongSubredditState, SubredditState
} from "./Common/interfaces";
import JSON5 from "json5";
@@ -30,6 +31,7 @@ import {create as createMemoryStore} from './Utils/memoryStore';
import {MESSAGE} from "triple-beam";
import {RedditUser} from "snoowrap/dist/objects";
import reRegExp from '@stdlib/regexp-regexp';
import fetch, {Response} from "node-fetch";
const ReReg = reRegExp();
@@ -841,8 +843,8 @@ export const boolToString = (val: boolean): string => {
return val ? 'Yes' : 'No';
}
export const isRedditMedia = (act: Submission): boolean => {
return act.is_reddit_media_domain || act.is_video || ['v.redd.it','i.redd.it'].includes(act.domain);
export const isRedditMedia = (act: Comment | Submission): boolean => {
return asSubmission(act) && (act.is_reddit_media_domain || act.is_video || ['v.redd.it','i.redd.it'].includes(act.domain));
}
export const isExternalUrlSubmission = (act: Comment | Submission): boolean => {
@@ -1075,6 +1077,10 @@ export const isScopeError = (err: any): boolean => {
return false;
}
export const isStatusError = (err: any): err is StatusCodeError => {
return typeof err === 'object' && err.name === 'StatusCodeError' && err.response !== undefined;
}
/**
* Cached activities lose type information when deserialized so need to check properties as well to see if the object is the shape of a Submission
* */
@@ -1154,3 +1160,78 @@ export const parseRuleResultsToMarkdownSummary = (ruleResults: RuleResult[]): st
});
return results.join('\r\n');
}
export const isValidImageURL = (str: string): boolean => {
return !!str.match(/\w+\.(jpg|jpeg|gif|png|tiff|bmp)$/gi);
}
export const getImageDataFromUrl = async (url: string, aggressive = false): Promise<[Response?, ImageData?, string?]> => {
if (!aggressive && !isValidImageURL(url)) {
return [undefined, undefined, 'URL did not end with a valid image extension'];
}
try {
const response = await fetch(url);
if (response.ok) {
const ct = response.headers.get('Content-Type');
if (ct !== null && ct.includes('image')) {
let buffer = await response.buffer();
const dimensions = sizeOf(buffer);
return [response, {
data: buffer,
width: dimensions.width as number - 5,
height: dimensions.height as number - 5,
pixels: (dimensions.height as number - 5) * (dimensions.width as number - 5)
}];
}
return [response, undefined, 'Content-Type for fetched URL did not contain "image"'];
}
return [response, undefined, `URL response was not OK: (${response.status})${response.statusText}`];
} catch (err) {
return [undefined, undefined, `Error occurred while fetching response from URL: ${err.message}`];
}
}
let resembleCIFunc: Function;
const getCIFunc = async () => {
if (resembleCIFunc === undefined) {
// @ts-ignore
const resembleModule = await import('resemblejs/compareImages');
if (resembleModule === undefined) {
throw new Error('Could not import resemblejs');
}
resembleCIFunc = resembleModule.default;
}
return resembleCIFunc;
}
export const compareImages = async (data1: ImageData, data2: ImageData, threshold?: number): Promise<[ResembleResult, boolean?]> => {
let ci: Function;
try {
ci = await getCIFunc();
} catch (err) {
err.message = `Unable to do image comparison due to an issue importing the comparison library. It is likely 'node-canvas' is not installed (see ContextMod docs). Error Message: ${err.message}`;
throw err;
}
const results = await ci(data1.data, data2.data, {
returnEarlyThreshold: threshold !== undefined ? Math.min(threshold + 5, 100) : undefined,
}) as ResembleResult;
const sameImage = threshold === undefined ? undefined : results.rawMisMatchPercentage < threshold;
return [results, sameImage];
}
export const createHistoricalStatsDisplay = (data: HistoricalStats): HistoricalStatsDisplay => {
const display: any = {};
for(const [k, v] of Object.entries(data)) {
if(v instanceof Map) {
display[k] = v;
display[`${k}Total`] = Array.from(v.values()).reduce((acc, curr) => acc + curr, 0);
} else {
display[k] = v;
}
}
return display as HistoricalStatsDisplay;
}