@joliegg/moderation 0.6.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions.d.ts +28 -0
- package/dist/actions.js +48 -0
- package/dist/audit/emitter.d.ts +26 -0
- package/dist/audit/emitter.js +63 -0
- package/dist/audit/events.d.ts +75 -0
- package/dist/audit/events.js +2 -0
- package/dist/audit/index.d.ts +2 -0
- package/dist/audit/index.js +18 -0
- package/dist/client.d.ts +22 -0
- package/dist/client.js +107 -0
- package/dist/index.d.ts +3 -41
- package/dist/index.js +20 -213
- package/dist/providers/aws.d.ts +11 -0
- package/dist/providers/aws.js +58 -0
- package/dist/providers/google.d.ts +21 -0
- package/dist/providers/google.js +61 -0
- package/dist/providers/openai.d.ts +15 -0
- package/dist/providers/openai.js +54 -0
- package/dist/providers/webrisk.d.ts +9 -0
- package/dist/providers/webrisk.js +33 -0
- package/dist/raid/age.d.ts +6 -0
- package/dist/raid/age.js +19 -0
- package/dist/raid/detector.d.ts +56 -0
- package/dist/raid/detector.js +90 -0
- package/dist/raid/index.d.ts +2 -0
- package/dist/raid/index.js +18 -0
- package/dist/rubrics/defaults.d.ts +19 -0
- package/dist/rubrics/defaults.js +32 -0
- package/dist/rubrics/index.d.ts +3 -0
- package/dist/rubrics/index.js +19 -0
- package/dist/rubrics/rubric.d.ts +21 -0
- package/dist/rubrics/rubric.js +57 -0
- package/dist/rubrics/types.d.ts +27 -0
- package/dist/rubrics/types.js +2 -0
- package/dist/spam/cache.d.ts +99 -0
- package/dist/spam/cache.js +210 -0
- package/dist/spam/index.d.ts +1 -0
- package/dist/spam/index.js +17 -0
- package/dist/text/index.d.ts +2 -0
- package/dist/text/index.js +18 -0
- package/dist/text/mentions.d.ts +31 -0
- package/dist/text/mentions.js +55 -0
- package/dist/text/normalize.d.ts +15 -0
- package/dist/text/normalize.js +45 -0
- package/dist/types/config.d.ts +13 -0
- package/dist/types/config.js +2 -0
- package/dist/types/index.d.ts +3 -10
- package/dist/types/index.js +15 -0
- package/package.json +66 -13
- package/src/actions.ts +50 -0
- package/src/audit/emitter.ts +77 -0
- package/src/audit/events.ts +89 -0
- package/src/audit/index.ts +2 -0
- package/src/client.ts +137 -0
- package/src/index.ts +3 -277
- package/src/providers/aws.ts +58 -0
- package/src/providers/google.ts +63 -0
- package/src/providers/openai.ts +64 -0
- package/src/providers/webrisk.ts +30 -0
- package/src/raid/age.ts +19 -0
- package/src/raid/detector.ts +133 -0
- package/src/raid/index.ts +2 -0
- package/src/rubrics/defaults.ts +32 -0
- package/src/rubrics/index.ts +3 -0
- package/src/rubrics/rubric.ts +62 -0
- package/src/rubrics/types.ts +30 -0
- package/src/spam/cache.ts +342 -0
- package/src/spam/index.ts +1 -0
- package/src/text/index.ts +2 -0
- package/src/text/mentions.ts +91 -0
- package/src/text/normalize.ts +43 -0
- package/src/types/config.ts +14 -0
- package/src/types/index.ts +5 -11
- /package/dist/{url-blacklist.json → data/url-blacklist.json} +0 -0
- /package/dist/{url-shorteners.json → data/url-shorteners.json} +0 -0
- /package/src/{url-blacklist.json → data/url-blacklist.json} +0 -0
- /package/src/{url-shorteners.json → data/url-shorteners.json} +0 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export interface MentionConfig {
|
|
2
|
+
/** Maximum number of distinct user mentions per message. */
|
|
3
|
+
maxUserMentions: number;
|
|
4
|
+
/** Maximum number of distinct role mentions per message. */
|
|
5
|
+
maxRoleMentions: number;
|
|
6
|
+
/** Maximum number of total mentions (user + role) per message. */
|
|
7
|
+
maxTotalMentions: number;
|
|
8
|
+
/** Whether to treat `@everyone` as spam for the sender. */
|
|
9
|
+
blockEveryone: boolean;
|
|
10
|
+
/** Whether to treat `@here` as spam for the sender. */
|
|
11
|
+
blockHere: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface MentionCounts {
|
|
14
|
+
userMentions: number;
|
|
15
|
+
roleMentions: number;
|
|
16
|
+
hasEveryone: boolean;
|
|
17
|
+
hasHere: boolean;
|
|
18
|
+
}
|
|
19
|
+
export type MentionSpamReason = 'mention_everyone' | 'mention_here' | 'mention_users' | 'mention_roles' | 'mention_total';
|
|
20
|
+
export interface MentionSpamResult {
|
|
21
|
+
isSpam: boolean;
|
|
22
|
+
reason: MentionSpamReason | null;
|
|
23
|
+
details: string | null;
|
|
24
|
+
}
|
|
25
|
+
export declare const DEFAULT_MENTION_CONFIG: MentionConfig;
|
|
26
|
+
/**
|
|
27
|
+
* Mention-spam check.
|
|
28
|
+
*
|
|
29
|
+
* `@everyone` detection takes priority over other reasons.
|
|
30
|
+
*/
|
|
31
|
+
export declare function checkMentionSpam(counts: MentionCounts, config: MentionConfig): MentionSpamResult;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_MENTION_CONFIG = void 0;
|
|
4
|
+
exports.checkMentionSpam = checkMentionSpam;
|
|
5
|
+
exports.DEFAULT_MENTION_CONFIG = {
|
|
6
|
+
maxUserMentions: 5,
|
|
7
|
+
maxRoleMentions: 3,
|
|
8
|
+
maxTotalMentions: 8,
|
|
9
|
+
blockEveryone: true,
|
|
10
|
+
blockHere: true,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Mention-spam check.
|
|
14
|
+
*
|
|
15
|
+
* `@everyone` detection takes priority over other reasons.
|
|
16
|
+
*/
|
|
17
|
+
function checkMentionSpam(counts, config) {
|
|
18
|
+
if (config.blockEveryone && counts.hasEveryone) {
|
|
19
|
+
return {
|
|
20
|
+
isSpam: true,
|
|
21
|
+
reason: 'mention_everyone',
|
|
22
|
+
details: '@everyone mentioned without permission',
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
if (config.blockHere && counts.hasHere) {
|
|
26
|
+
return {
|
|
27
|
+
isSpam: true,
|
|
28
|
+
reason: 'mention_here',
|
|
29
|
+
details: '@here mentioned without permission',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (counts.userMentions > config.maxUserMentions) {
|
|
33
|
+
return {
|
|
34
|
+
isSpam: true,
|
|
35
|
+
reason: 'mention_users',
|
|
36
|
+
details: `${counts.userMentions} user mentions (limit: ${config.maxUserMentions})`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (counts.roleMentions > config.maxRoleMentions) {
|
|
40
|
+
return {
|
|
41
|
+
isSpam: true,
|
|
42
|
+
reason: 'mention_roles',
|
|
43
|
+
details: `${counts.roleMentions} role mentions (limit: ${config.maxRoleMentions})`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const total = counts.userMentions + counts.roleMentions;
|
|
47
|
+
if (total > config.maxTotalMentions) {
|
|
48
|
+
return {
|
|
49
|
+
isSpam: true,
|
|
50
|
+
reason: 'mention_total',
|
|
51
|
+
details: `${total} total mentions (limit: ${config.maxTotalMentions})`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return { isSpam: false, reason: null, details: null };
|
|
55
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonicalizes user-submitted text for content matching:
|
|
3
|
+
*
|
|
4
|
+
* 1. trim surrounding whitespace
|
|
5
|
+
* 2. lowercase
|
|
6
|
+
* 3. strip zero-width / invisible characters
|
|
7
|
+
* 4. NFKC normalize (collapses bold, italic, fullwidth, circled,
|
|
8
|
+
* small-caps, and other compatibility variants to ASCII)
|
|
9
|
+
* 5. collapse internal whitespace runs to single spaces
|
|
10
|
+
*
|
|
11
|
+
* Useful for spam hashing, ban-list matching, and any other comparison
|
|
12
|
+
* where users should not be able to defeat a match by visually similar
|
|
13
|
+
* but technically distinct input.
|
|
14
|
+
*/
|
|
15
|
+
export declare function normalizeText(input: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.normalizeText = normalizeText;
|
|
4
|
+
/**
|
|
5
|
+
* Invisible / zero-width Unicode code points that users sometimes insert
|
|
6
|
+
* between letters to bypass substring-based filters. NFKC normalization
|
|
7
|
+
* does NOT collapse these on its own, so we strip them explicitly before
|
|
8
|
+
* normalizing.
|
|
9
|
+
*
|
|
10
|
+
* - U+200B Zero-Width Space
|
|
11
|
+
* - U+200C Zero-Width Non-Joiner
|
|
12
|
+
* - U+200D Zero-Width Joiner
|
|
13
|
+
* - U+200E Left-to-Right Mark
|
|
14
|
+
* - U+200F Right-to-Left Mark
|
|
15
|
+
* - U+2060 Word Joiner
|
|
16
|
+
* - U+FEFF Zero-Width No-Break Space (BOM)
|
|
17
|
+
*/
|
|
18
|
+
// Matching individual zero-width code points by design (we are stripping
|
|
19
|
+
// them, not joining anything). ESLint's no-misleading-character-class
|
|
20
|
+
// flags this since \u200d is the Zero-Width Joiner; the warning does not
|
|
21
|
+
// apply here because every code point in the class is a literal target.
|
|
22
|
+
// eslint-disable-next-line no-misleading-character-class
|
|
23
|
+
const ZERO_WIDTH = /[\u200b\u200c\u200d\u200e\u200f\u2060\ufeff]/gu;
|
|
24
|
+
/**
|
|
25
|
+
* Canonicalizes user-submitted text for content matching:
|
|
26
|
+
*
|
|
27
|
+
* 1. trim surrounding whitespace
|
|
28
|
+
* 2. lowercase
|
|
29
|
+
* 3. strip zero-width / invisible characters
|
|
30
|
+
* 4. NFKC normalize (collapses bold, italic, fullwidth, circled,
|
|
31
|
+
* small-caps, and other compatibility variants to ASCII)
|
|
32
|
+
* 5. collapse internal whitespace runs to single spaces
|
|
33
|
+
*
|
|
34
|
+
* Useful for spam hashing, ban-list matching, and any other comparison
|
|
35
|
+
* where users should not be able to defeat a match by visually similar
|
|
36
|
+
* but technically distinct input.
|
|
37
|
+
*/
|
|
38
|
+
function normalizeText(input) {
|
|
39
|
+
return input
|
|
40
|
+
.trim()
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.replace(ZERO_WIDTH, '')
|
|
43
|
+
.normalize('NFKC')
|
|
44
|
+
.replace(/\s+/g, ' ');
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RekognitionClientConfig } from '@aws-sdk/client-rekognition';
|
|
2
|
+
export interface ModerationConfiguration {
|
|
3
|
+
aws?: RekognitionClientConfig;
|
|
4
|
+
google?: {
|
|
5
|
+
apiKey?: string;
|
|
6
|
+
keyFile?: string;
|
|
7
|
+
};
|
|
8
|
+
openai?: {
|
|
9
|
+
apiKey?: string;
|
|
10
|
+
};
|
|
11
|
+
banList?: string[];
|
|
12
|
+
urlBlackList?: string[];
|
|
13
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
export
|
|
3
|
-
aws?: RekognitionClientConfig;
|
|
4
|
-
google?: {
|
|
5
|
-
apiKey?: string;
|
|
6
|
-
keyFile?: string;
|
|
7
|
-
};
|
|
8
|
-
banList?: string[];
|
|
9
|
-
urlBlackList?: string[];
|
|
10
|
-
}
|
|
1
|
+
export * from './config';
|
|
2
|
+
export type Severity = 'low' | 'medium' | 'high' | 'critical';
|
|
11
3
|
export interface ModerationCategory {
|
|
12
4
|
category: string;
|
|
13
5
|
confidence: number;
|
|
6
|
+
severity?: Severity;
|
|
14
7
|
}
|
|
15
8
|
export interface ModerationResult {
|
|
16
9
|
source: string;
|
package/dist/types/index.js
CHANGED
|
@@ -1,2 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
2
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
__exportStar(require("./config"), exports);
|
package/package.json
CHANGED
|
@@ -1,15 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@joliegg/moderation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A set of tools for chat moderation",
|
|
5
5
|
"author": "Diana Islas Ocampo",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
7
8
|
"license": "MIT",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./types": {
|
|
15
|
+
"types": "./dist/types/index.d.ts",
|
|
16
|
+
"default": "./dist/types/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./actions": {
|
|
19
|
+
"types": "./dist/actions.d.ts",
|
|
20
|
+
"default": "./dist/actions.js"
|
|
21
|
+
},
|
|
22
|
+
"./text": {
|
|
23
|
+
"types": "./dist/text/index.d.ts",
|
|
24
|
+
"default": "./dist/text/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./spam": {
|
|
27
|
+
"types": "./dist/spam/index.d.ts",
|
|
28
|
+
"default": "./dist/spam/index.js"
|
|
29
|
+
},
|
|
30
|
+
"./raid": {
|
|
31
|
+
"types": "./dist/raid/index.d.ts",
|
|
32
|
+
"default": "./dist/raid/index.js"
|
|
33
|
+
},
|
|
34
|
+
"./rubrics": {
|
|
35
|
+
"types": "./dist/rubrics/index.d.ts",
|
|
36
|
+
"default": "./dist/rubrics/index.js"
|
|
37
|
+
},
|
|
38
|
+
"./audit": {
|
|
39
|
+
"types": "./dist/audit/index.d.ts",
|
|
40
|
+
"default": "./dist/audit/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./providers/google": {
|
|
43
|
+
"types": "./dist/providers/google.d.ts",
|
|
44
|
+
"default": "./dist/providers/google.js"
|
|
45
|
+
},
|
|
46
|
+
"./providers/aws": {
|
|
47
|
+
"types": "./dist/providers/aws.d.ts",
|
|
48
|
+
"default": "./dist/providers/aws.js"
|
|
49
|
+
},
|
|
50
|
+
"./providers/webrisk": {
|
|
51
|
+
"types": "./dist/providers/webrisk.d.ts",
|
|
52
|
+
"default": "./dist/providers/webrisk.js"
|
|
53
|
+
},
|
|
54
|
+
"./providers/openai": {
|
|
55
|
+
"types": "./dist/providers/openai.d.ts",
|
|
56
|
+
"default": "./dist/providers/openai.js"
|
|
57
|
+
},
|
|
58
|
+
"./package.json": "./package.json"
|
|
59
|
+
},
|
|
8
60
|
"scripts": {
|
|
9
|
-
"
|
|
61
|
+
"prepublishOnly": "bun run build && bun run docs",
|
|
10
62
|
"build": "bun eslint . && rm -rf ./dist && bun tsc --declaration",
|
|
11
63
|
"docs": "typedoc",
|
|
12
|
-
"test": "
|
|
64
|
+
"test": "bun test",
|
|
65
|
+
"test:integration": "bun run test/integration.ts"
|
|
13
66
|
},
|
|
14
67
|
"engines": {
|
|
15
68
|
"node": ">=20.x"
|
|
@@ -17,19 +70,19 @@
|
|
|
17
70
|
"devDependencies": {
|
|
18
71
|
"@babel/eslint-parser": "^7.28.6",
|
|
19
72
|
"@eslint/js": "^10.0.1",
|
|
20
|
-
"@typescript-eslint/eslint-plugin": "^8.
|
|
21
|
-
"@typescript-eslint/parser": "^8.
|
|
22
|
-
"dotenv": "^17.
|
|
23
|
-
"eslint": "^10.0
|
|
24
|
-
"typedoc": "^0.28.
|
|
73
|
+
"@typescript-eslint/eslint-plugin": "^8.58.2",
|
|
74
|
+
"@typescript-eslint/parser": "^8.58.2",
|
|
75
|
+
"dotenv": "^17.4.2",
|
|
76
|
+
"eslint": "^10.2.0",
|
|
77
|
+
"typedoc": "^0.28.19",
|
|
25
78
|
"typescript": "^5.9.3",
|
|
26
|
-
"typescript-eslint": "^8.
|
|
79
|
+
"typescript-eslint": "^8.58.2"
|
|
27
80
|
},
|
|
28
81
|
"dependencies": {
|
|
29
|
-
"@aws-sdk/client-rekognition": "^3.
|
|
82
|
+
"@aws-sdk/client-rekognition": "^3.1031.0",
|
|
30
83
|
"@google-cloud/language": "^7.2.1",
|
|
31
|
-
"@google-cloud/speech": "^7.
|
|
32
|
-
"axios": "^1.
|
|
84
|
+
"@google-cloud/speech": "^7.3.0",
|
|
85
|
+
"axios": "^1.15.0",
|
|
33
86
|
"sharp": "^0.34.5"
|
|
34
87
|
},
|
|
35
88
|
"files": [
|
|
@@ -40,4 +93,4 @@
|
|
|
40
93
|
"dist/*"
|
|
41
94
|
],
|
|
42
95
|
"packageManager": "bun@1.3.4"
|
|
43
|
-
}
|
|
96
|
+
}
|
package/src/actions.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Severity } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Moderation actions taxonomy. These should be stable so they can be persisted
|
|
5
|
+
* as a canonical list of actions.
|
|
6
|
+
*/
|
|
7
|
+
export const ACTION_TYPES = {
|
|
8
|
+
TIMEOUT: 'timeout',
|
|
9
|
+
BAN: 'ban',
|
|
10
|
+
KICK: 'kick',
|
|
11
|
+
WARN: 'warn',
|
|
12
|
+
DELETE: 'delete',
|
|
13
|
+
RESTRICT: 'restrict',
|
|
14
|
+
APPEAL_APPROVE: 'appeal_approve',
|
|
15
|
+
APPEAL_DENY: 'appeal_deny',
|
|
16
|
+
MENTION_SPAM: 'mention_spam',
|
|
17
|
+
NEW_USER_RESTRICT: 'new_user_restrict',
|
|
18
|
+
SPAM_DETECTED: 'spam_detected',
|
|
19
|
+
ESCALATION: 'escalation',
|
|
20
|
+
RAID_DETECTED: 'raid_detected',
|
|
21
|
+
RAID_TIMEOUT: 'raid_timeout',
|
|
22
|
+
RAID_JOIN: 'raid_join',
|
|
23
|
+
PERMISSION_BLOCK: 'permission_block',
|
|
24
|
+
UNTIMEOUT: 'untimeout',
|
|
25
|
+
UNBAN: 'unban',
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
export type ActionType = typeof ACTION_TYPES[keyof typeof ACTION_TYPES];
|
|
29
|
+
|
|
30
|
+
/** Default severity per action type. */
|
|
31
|
+
export const SEVERITY_BY_ACTION: Record<ActionType, Severity> = {
|
|
32
|
+
timeout: 'medium',
|
|
33
|
+
ban: 'critical',
|
|
34
|
+
kick: 'high',
|
|
35
|
+
warn: 'low',
|
|
36
|
+
delete: 'low',
|
|
37
|
+
restrict: 'medium',
|
|
38
|
+
appeal_approve: 'low',
|
|
39
|
+
appeal_deny: 'low',
|
|
40
|
+
mention_spam: 'medium',
|
|
41
|
+
new_user_restrict: 'low',
|
|
42
|
+
spam_detected: 'medium',
|
|
43
|
+
escalation: 'high',
|
|
44
|
+
raid_detected: 'critical',
|
|
45
|
+
raid_timeout: 'medium',
|
|
46
|
+
raid_join: 'low',
|
|
47
|
+
permission_block: 'low',
|
|
48
|
+
untimeout: 'low',
|
|
49
|
+
unban: 'low',
|
|
50
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AuditEvent, AuditEventKind, AuditEventMap } from './events';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Subscriber handler type for a specific audit event kind. Sync or
|
|
5
|
+
* async; the emitter fire-and-forgets async handlers so slow
|
|
6
|
+
* persistence sinks don't block the enforcement path.
|
|
7
|
+
*/
|
|
8
|
+
export type AuditHandler<K extends AuditEventKind> = (event: AuditEventMap[K]) => void | Promise<void>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Typed event emitter for moderation audit events.
|
|
12
|
+
*
|
|
13
|
+
* Subscribers register for specific event kinds with full type safety.
|
|
14
|
+
* Handlers that throw are isolated — a broken subscriber never blocks
|
|
15
|
+
* other subscribers or the emitter itself.
|
|
16
|
+
*
|
|
17
|
+
* Intentionally NOT `extends EventEmitter` from `node:events`: the
|
|
18
|
+
* typing story for Node's built-in emitter is painful, and the feature
|
|
19
|
+
* set we need (on / off / emit) is small enough to implement directly.
|
|
20
|
+
*/
|
|
21
|
+
export class AuditTrailEmitter {
|
|
22
|
+
private handlers = new Map<AuditEventKind, Set<AuditHandler<AuditEventKind>>>();
|
|
23
|
+
|
|
24
|
+
on<K extends AuditEventKind>(kind: K, handler: AuditHandler<K>): this {
|
|
25
|
+
if (!this.handlers.has(kind)) {
|
|
26
|
+
this.handlers.set(kind, new Set());
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
this.handlers.get(kind)!.add(handler as AuditHandler<AuditEventKind>);
|
|
30
|
+
|
|
31
|
+
return this;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
off<K extends AuditEventKind>(kind: K, handler: AuditHandler<K>): this {
|
|
35
|
+
this.handlers.get(kind)?.delete(handler as AuditHandler<AuditEventKind>);
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
emit(event: AuditEvent): void {
|
|
40
|
+
const handlers = this.handlers.get(event.kind);
|
|
41
|
+
|
|
42
|
+
if (!handlers) {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
for (const handler of handlers) {
|
|
47
|
+
try {
|
|
48
|
+
// Fire-and-forget. Async handlers are not awaited so the
|
|
49
|
+
// emitter's caller isn't blocked on slow persistence sinks.
|
|
50
|
+
// Subscribers own their own error handling for async work.
|
|
51
|
+
const result = handler(event);
|
|
52
|
+
|
|
53
|
+
if (result instanceof Promise) {
|
|
54
|
+
result.catch(err => {
|
|
55
|
+
console.error(`[Jolie::Moderation::AuditTrail] async handler error for ${event.kind}:`, err);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error(`[Jolie::Moderation::AuditTrail] handler error for ${event.kind}:`, err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
listenerCount(kind: AuditEventKind): number {
|
|
65
|
+
return this.handlers.get(kind)?.size ?? 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
removeAllListeners(kind?: AuditEventKind): this {
|
|
69
|
+
if (kind) {
|
|
70
|
+
this.handlers.delete(kind);
|
|
71
|
+
} else {
|
|
72
|
+
this.handlers.clear();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ActionType } from '../actions';
|
|
2
|
+
import type { SpamMessageRef, SpamReason } from '../spam/cache';
|
|
3
|
+
|
|
4
|
+
export type Platform = 'discord' | 'twitch';
|
|
5
|
+
|
|
6
|
+
/** Base fields shared by every audit event. */
|
|
7
|
+
interface AuditEventBase {
|
|
8
|
+
guildId: string;
|
|
9
|
+
platform: Platform;
|
|
10
|
+
timestamp: Date;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A moderator (human or system) performed an enforcement action.
|
|
15
|
+
* This is the primary audit log entry.
|
|
16
|
+
*/
|
|
17
|
+
export interface ActionEvent extends AuditEventBase {
|
|
18
|
+
kind: 'action';
|
|
19
|
+
actionType: ActionType;
|
|
20
|
+
targetId: string;
|
|
21
|
+
targetName: string;
|
|
22
|
+
moderatorId: string;
|
|
23
|
+
moderatorName: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
details?: string;
|
|
26
|
+
duration?: number;
|
|
27
|
+
channelId?: string;
|
|
28
|
+
messageId?: string;
|
|
29
|
+
evidence?: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Automatic spam detection fired. Independent of whether enforcement
|
|
34
|
+
* succeeded — the detection itself is the audit event.
|
|
35
|
+
*/
|
|
36
|
+
export interface SpamDetectedEvent extends AuditEventBase {
|
|
37
|
+
kind: 'spam_detected';
|
|
38
|
+
userId: string;
|
|
39
|
+
reason: SpamReason;
|
|
40
|
+
details?: string;
|
|
41
|
+
priorMessageIds: SpamMessageRef[];
|
|
42
|
+
channelId?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Raid mode detection crossed the join-count threshold. */
|
|
46
|
+
export interface RaidDetectedEvent extends AuditEventBase {
|
|
47
|
+
kind: 'raid_detected';
|
|
48
|
+
joinCount: number;
|
|
49
|
+
windowSeconds: number;
|
|
50
|
+
autoTriggered: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A message was blocked by the per-user permissions check. */
|
|
54
|
+
export interface PermissionBlockEvent extends AuditEventBase {
|
|
55
|
+
kind: 'permission_block';
|
|
56
|
+
userId: string;
|
|
57
|
+
violations: string[];
|
|
58
|
+
channelId?: string;
|
|
59
|
+
messageId?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An appeal was reviewed (approved or denied). */
|
|
63
|
+
export interface AppealReviewedEvent extends AuditEventBase {
|
|
64
|
+
kind: 'appeal_reviewed';
|
|
65
|
+
appealId: string;
|
|
66
|
+
userId: string;
|
|
67
|
+
reviewerId: string;
|
|
68
|
+
approved: boolean;
|
|
69
|
+
note?: string;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Union of every audit event shape. Add new kinds here. */
|
|
73
|
+
export type AuditEvent =
|
|
74
|
+
| ActionEvent
|
|
75
|
+
| SpamDetectedEvent
|
|
76
|
+
| RaidDetectedEvent
|
|
77
|
+
| PermissionBlockEvent
|
|
78
|
+
| AppealReviewedEvent;
|
|
79
|
+
|
|
80
|
+
/** Map from event kind → concrete event type. Used by the emitter. */
|
|
81
|
+
export interface AuditEventMap {
|
|
82
|
+
action: ActionEvent;
|
|
83
|
+
spam_detected: SpamDetectedEvent;
|
|
84
|
+
raid_detected: RaidDetectedEvent;
|
|
85
|
+
permission_block: PermissionBlockEvent;
|
|
86
|
+
appeal_reviewed: AppealReviewedEvent;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export type AuditEventKind = keyof AuditEventMap;
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { Rekognition } from '@aws-sdk/client-rekognition';
|
|
2
|
+
import { LanguageServiceClient } from '@google-cloud/language';
|
|
3
|
+
import { SpeechClient } from '@google-cloud/speech';
|
|
4
|
+
|
|
5
|
+
import { GoogleLanguageProvider, GoogleSpeechProvider, fetchAudio } from './providers/google';
|
|
6
|
+
import { RekognitionProvider } from './providers/aws';
|
|
7
|
+
import { WebRiskProvider } from './providers/webrisk';
|
|
8
|
+
import { OpenAIModerationProvider } from './providers/openai';
|
|
9
|
+
import URLBlackList from './data/url-blacklist.json';
|
|
10
|
+
import URLShortenerList from './data/url-shorteners.json';
|
|
11
|
+
|
|
12
|
+
import type { ModerationCategory, ModerationConfiguration, ModerationResult } from './types';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Composes text / image / link / audio moderation across the configured
|
|
16
|
+
* providers.
|
|
17
|
+
*/
|
|
18
|
+
export class ModerationClient {
|
|
19
|
+
private googleLanguage?: GoogleLanguageProvider;
|
|
20
|
+
private googleSpeech?: GoogleSpeechProvider;
|
|
21
|
+
private aws?: RekognitionProvider;
|
|
22
|
+
private webRisk?: WebRiskProvider;
|
|
23
|
+
private openai?: OpenAIModerationProvider;
|
|
24
|
+
private banList: string[] = [];
|
|
25
|
+
private urlBlackList: string[] = [];
|
|
26
|
+
|
|
27
|
+
constructor(configuration: ModerationConfiguration) {
|
|
28
|
+
if (configuration.aws) {
|
|
29
|
+
this.aws = new RekognitionProvider(new Rekognition(configuration.aws));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (typeof configuration.google?.keyFile === 'string') {
|
|
33
|
+
this.googleLanguage = new GoogleLanguageProvider(
|
|
34
|
+
new LanguageServiceClient({ keyFile: configuration.google.keyFile })
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
this.googleSpeech = new GoogleSpeechProvider(
|
|
38
|
+
new SpeechClient({ keyFile: configuration.google.keyFile })
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (typeof configuration.google?.apiKey === 'string') {
|
|
43
|
+
this.webRisk = new WebRiskProvider(configuration.google.apiKey);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof configuration.openai?.apiKey === 'string') {
|
|
47
|
+
this.openai = new OpenAIModerationProvider(configuration.openai.apiKey);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (Array.isArray(configuration.banList)) {
|
|
51
|
+
this.banList = configuration.banList;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (Array.isArray(configuration.urlBlackList)) {
|
|
55
|
+
this.urlBlackList = configuration.urlBlackList;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async moderateText(
|
|
60
|
+
text: string,
|
|
61
|
+
minimumConfidence: number = 50,
|
|
62
|
+
options: { provider?: 'google' | 'openai' | 'all' } = {},
|
|
63
|
+
): Promise<ModerationResult> {
|
|
64
|
+
const categories: ModerationCategory[] = [];
|
|
65
|
+
const normalized = text.toLowerCase();
|
|
66
|
+
const matches = this.banList.filter(w => normalized.includes(w));
|
|
67
|
+
|
|
68
|
+
if (matches.length > 0) {
|
|
69
|
+
const words = normalized.split(' ');
|
|
70
|
+
categories.push({ category: 'BAN_LIST', confidence: (matches.length / words.length) * 100 });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const provider = options.provider ?? 'google';
|
|
74
|
+
|
|
75
|
+
if ((provider === 'google' || provider === 'all') && this.googleLanguage) {
|
|
76
|
+
const google = await this.googleLanguage.moderateText(text, minimumConfidence);
|
|
77
|
+
categories.push(...google);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if ((provider === 'openai' || provider === 'all') && this.openai) {
|
|
81
|
+
const openai = await this.openai.moderateText(text, minimumConfidence);
|
|
82
|
+
categories.push(...openai);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { source: text, moderation: categories };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async moderateImage(url: string, minimumConfidence: number = 50): Promise<ModerationResult> {
|
|
89
|
+
if (!this.aws) {
|
|
90
|
+
return { source: url, moderation: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const moderation = await this.aws.moderateImage(url, minimumConfidence);
|
|
94
|
+
|
|
95
|
+
return { source: url, moderation };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async moderateLink(url: string, allowShorteners = false): Promise<ModerationResult> {
|
|
99
|
+
try {
|
|
100
|
+
const domain = new URL(url).hostname;
|
|
101
|
+
|
|
102
|
+
if (this.urlBlackList.some(u => url.includes(u))) {
|
|
103
|
+
return { source: url, moderation: [{ category: 'CUSTOM_BLACK_LIST', confidence: 100 }] };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (URLBlackList.some(u => u === domain)) {
|
|
107
|
+
return { source: url, moderation: [{ category: 'BLACK_LIST', confidence: 100 }] };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (!allowShorteners && URLShortenerList.some(u => u === domain)) {
|
|
111
|
+
return { source: url, moderation: [{ category: 'URL_SHORTENER', confidence: 100 }] };
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
return { source: url, moderation: [] };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (!this.webRisk) {
|
|
118
|
+
return { source: url, moderation: [] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const moderation = await this.webRisk.checkLink(url);
|
|
122
|
+
return { source: url, moderation };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async moderateAudio(url: string, language: string = 'en-US', minimumConfidence: number = 50): Promise<ModerationResult> {
|
|
126
|
+
if (!this.googleSpeech) {
|
|
127
|
+
return { source: url, moderation: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const buffer = await fetchAudio(url);
|
|
131
|
+
const transcription = await this.googleSpeech.transcribe(buffer, language);
|
|
132
|
+
|
|
133
|
+
return this.moderateText(transcription, minimumConfidence);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export default ModerationClient;
|