@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.
Files changed (77) hide show
  1. package/dist/actions.d.ts +28 -0
  2. package/dist/actions.js +48 -0
  3. package/dist/audit/emitter.d.ts +26 -0
  4. package/dist/audit/emitter.js +63 -0
  5. package/dist/audit/events.d.ts +75 -0
  6. package/dist/audit/events.js +2 -0
  7. package/dist/audit/index.d.ts +2 -0
  8. package/dist/audit/index.js +18 -0
  9. package/dist/client.d.ts +22 -0
  10. package/dist/client.js +107 -0
  11. package/dist/index.d.ts +3 -41
  12. package/dist/index.js +20 -213
  13. package/dist/providers/aws.d.ts +11 -0
  14. package/dist/providers/aws.js +58 -0
  15. package/dist/providers/google.d.ts +21 -0
  16. package/dist/providers/google.js +61 -0
  17. package/dist/providers/openai.d.ts +15 -0
  18. package/dist/providers/openai.js +54 -0
  19. package/dist/providers/webrisk.d.ts +9 -0
  20. package/dist/providers/webrisk.js +33 -0
  21. package/dist/raid/age.d.ts +6 -0
  22. package/dist/raid/age.js +19 -0
  23. package/dist/raid/detector.d.ts +56 -0
  24. package/dist/raid/detector.js +90 -0
  25. package/dist/raid/index.d.ts +2 -0
  26. package/dist/raid/index.js +18 -0
  27. package/dist/rubrics/defaults.d.ts +19 -0
  28. package/dist/rubrics/defaults.js +32 -0
  29. package/dist/rubrics/index.d.ts +3 -0
  30. package/dist/rubrics/index.js +19 -0
  31. package/dist/rubrics/rubric.d.ts +21 -0
  32. package/dist/rubrics/rubric.js +57 -0
  33. package/dist/rubrics/types.d.ts +27 -0
  34. package/dist/rubrics/types.js +2 -0
  35. package/dist/spam/cache.d.ts +99 -0
  36. package/dist/spam/cache.js +210 -0
  37. package/dist/spam/index.d.ts +1 -0
  38. package/dist/spam/index.js +17 -0
  39. package/dist/text/index.d.ts +2 -0
  40. package/dist/text/index.js +18 -0
  41. package/dist/text/mentions.d.ts +31 -0
  42. package/dist/text/mentions.js +55 -0
  43. package/dist/text/normalize.d.ts +15 -0
  44. package/dist/text/normalize.js +45 -0
  45. package/dist/types/config.d.ts +13 -0
  46. package/dist/types/config.js +2 -0
  47. package/dist/types/index.d.ts +3 -10
  48. package/dist/types/index.js +15 -0
  49. package/package.json +66 -13
  50. package/src/actions.ts +50 -0
  51. package/src/audit/emitter.ts +77 -0
  52. package/src/audit/events.ts +89 -0
  53. package/src/audit/index.ts +2 -0
  54. package/src/client.ts +137 -0
  55. package/src/index.ts +3 -277
  56. package/src/providers/aws.ts +58 -0
  57. package/src/providers/google.ts +63 -0
  58. package/src/providers/openai.ts +64 -0
  59. package/src/providers/webrisk.ts +30 -0
  60. package/src/raid/age.ts +19 -0
  61. package/src/raid/detector.ts +133 -0
  62. package/src/raid/index.ts +2 -0
  63. package/src/rubrics/defaults.ts +32 -0
  64. package/src/rubrics/index.ts +3 -0
  65. package/src/rubrics/rubric.ts +62 -0
  66. package/src/rubrics/types.ts +30 -0
  67. package/src/spam/cache.ts +342 -0
  68. package/src/spam/index.ts +1 -0
  69. package/src/text/index.ts +2 -0
  70. package/src/text/mentions.ts +91 -0
  71. package/src/text/normalize.ts +43 -0
  72. package/src/types/config.ts +14 -0
  73. package/src/types/index.ts +5 -11
  74. /package/dist/{url-blacklist.json → data/url-blacklist.json} +0 -0
  75. /package/dist/{url-shorteners.json → data/url-shorteners.json} +0 -0
  76. /package/src/{url-blacklist.json → data/url-blacklist.json} +0 -0
  77. /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
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -1,16 +1,9 @@
1
- import { RekognitionClientConfig } from '@aws-sdk/client-rekognition';
2
- export interface ModerationConfiguration {
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;
@@ -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.6.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
- "publish": "bun build && bun docs",
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": "node test/index.js"
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.56.1",
21
- "@typescript-eslint/parser": "^8.56.1",
22
- "dotenv": "^17.3.1",
23
- "eslint": "^10.0.2",
24
- "typedoc": "^0.28.17",
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.56.1"
79
+ "typescript-eslint": "^8.58.2"
27
80
  },
28
81
  "dependencies": {
29
- "@aws-sdk/client-rekognition": "^3.996.0",
82
+ "@aws-sdk/client-rekognition": "^3.1031.0",
30
83
  "@google-cloud/language": "^7.2.1",
31
- "@google-cloud/speech": "^7.2.1",
32
- "axios": "^1.13.5",
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;
@@ -0,0 +1,2 @@
1
+ export * from './events';
2
+ export * from './emitter';
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;