@zhin.js/plugin-content-moderation 1.0.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/README.md +173 -0
- package/lib/bypass.d.ts +10 -0
- package/lib/bypass.d.ts.map +1 -0
- package/lib/bypass.js +27 -0
- package/lib/bypass.js.map +1 -0
- package/lib/config.d.ts +5 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +184 -0
- package/lib/config.js.map +1 -0
- package/lib/engine.d.ts +40 -0
- package/lib/engine.d.ts.map +1 -0
- package/lib/engine.js +170 -0
- package/lib/engine.js.map +1 -0
- package/lib/extract.d.ts +18 -0
- package/lib/extract.d.ts.map +1 -0
- package/lib/extract.js +121 -0
- package/lib/extract.js.map +1 -0
- package/lib/index.d.ts +13 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/providers/builtin-lexicon.d.ts +15 -0
- package/lib/providers/builtin-lexicon.d.ts.map +1 -0
- package/lib/providers/builtin-lexicon.js +133 -0
- package/lib/providers/builtin-lexicon.js.map +1 -0
- package/lib/providers/http.d.ts +14 -0
- package/lib/providers/http.d.ts.map +1 -0
- package/lib/providers/http.js +191 -0
- package/lib/providers/http.js.map +1 -0
- package/lib/providers/local-lexicon.d.ts +28 -0
- package/lib/providers/local-lexicon.d.ts.map +1 -0
- package/lib/providers/local-lexicon.js +129 -0
- package/lib/providers/local-lexicon.js.map +1 -0
- package/lib/providers/registry.d.ts +8 -0
- package/lib/providers/registry.d.ts.map +1 -0
- package/lib/providers/registry.js +24 -0
- package/lib/providers/registry.js.map +1 -0
- package/lib/providers/types.d.ts +6 -0
- package/lib/providers/types.d.ts.map +1 -0
- package/lib/providers/types.js +2 -0
- package/lib/providers/types.js.map +1 -0
- package/lib/redact.d.ts +18 -0
- package/lib/redact.d.ts.map +1 -0
- package/lib/redact.js +102 -0
- package/lib/redact.js.map +1 -0
- package/lib/types.d.ts +105 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +26 -0
- package/lib/types.js.map +1 -0
- package/middlewares/inbound.js +97 -0
- package/middlewares/inbound.ts +112 -0
- package/middlewares/outbound.js +53 -0
- package/middlewares/outbound.ts +66 -0
- package/package.json +81 -0
- package/plugin.js +8 -0
- package/schema.json +240 -0
- package/src/bypass.ts +38 -0
- package/src/config.ts +203 -0
- package/src/engine.ts +220 -0
- package/src/extract.ts +155 -0
- package/src/index.ts +49 -0
- package/src/providers/builtin-lexicon.ts +149 -0
- package/src/providers/http.ts +225 -0
- package/src/providers/local-lexicon.ts +148 -0
- package/src/providers/registry.ts +37 -0
- package/src/providers/types.ts +6 -0
- package/src/redact.ts +141 -0
- package/src/types.ts +143 -0
package/src/redact.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ExtractedContent } from './extract.js';
|
|
2
|
+
import type { ExtractedImage, TextMatch } from './types.js';
|
|
3
|
+
|
|
4
|
+
export interface RedactOptions {
|
|
5
|
+
readonly maskChar: string;
|
|
6
|
+
readonly matches: readonly TextMatch[];
|
|
7
|
+
readonly flaggedImageIndexes: readonly number[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mask matched spans in text. When there are no spans but redact is requested
|
|
12
|
+
* for non-empty text, replace the whole string with mask chars.
|
|
13
|
+
*/
|
|
14
|
+
export function redactText(
|
|
15
|
+
text: string,
|
|
16
|
+
matches: readonly TextMatch[],
|
|
17
|
+
maskChar: string,
|
|
18
|
+
): string {
|
|
19
|
+
const ch = maskChar.slice(0, 1) || '*';
|
|
20
|
+
if (!text) return text;
|
|
21
|
+
if (!matches.length) {
|
|
22
|
+
const len = Math.max(3, Math.min(text.length, 16));
|
|
23
|
+
return ch.repeat(len);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const chars = [...text];
|
|
27
|
+
const sorted = [...matches].sort((a, b) => a.start - b.start);
|
|
28
|
+
for (const match of sorted) {
|
|
29
|
+
const start = Math.max(0, Math.min(chars.length, match.start));
|
|
30
|
+
const end = Math.max(start, Math.min(chars.length, match.end));
|
|
31
|
+
for (let i = start; i < end; i++) chars[i] = ch;
|
|
32
|
+
}
|
|
33
|
+
return chars.join('');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Apply text masking + remove flagged image segments from outbound payload.
|
|
38
|
+
*/
|
|
39
|
+
export function redactOutboundPayload(
|
|
40
|
+
payload: unknown,
|
|
41
|
+
extracted: ExtractedContent,
|
|
42
|
+
options: RedactOptions,
|
|
43
|
+
): unknown {
|
|
44
|
+
const flagged = new Set(options.flaggedImageIndexes);
|
|
45
|
+
const flaggedSegmentIndexes = new Set(
|
|
46
|
+
extracted.images
|
|
47
|
+
.filter((img) => flagged.has(img.index) && img.segmentIndex != null)
|
|
48
|
+
.map((img) => img.segmentIndex as number),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (typeof payload === 'string') {
|
|
52
|
+
return redactText(payload, options.matches, options.maskChar);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (Array.isArray(payload)) {
|
|
56
|
+
return redactSegmentList(payload, options, flaggedSegmentIndexes);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (payload && typeof payload === 'object' && 'type' in payload) {
|
|
60
|
+
const list = redactSegmentList([payload], options, flaggedSegmentIndexes);
|
|
61
|
+
return list.length === 1 ? list[0] : list;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (extracted.text) {
|
|
65
|
+
return redactText(extracted.text, options.matches, options.maskChar);
|
|
66
|
+
}
|
|
67
|
+
return payload;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function redactSegmentList(
|
|
71
|
+
segments: readonly unknown[],
|
|
72
|
+
options: RedactOptions,
|
|
73
|
+
flaggedSegmentIndexes: ReadonlySet<number>,
|
|
74
|
+
): unknown[] {
|
|
75
|
+
const out: unknown[] = [];
|
|
76
|
+
let textCursor = 0;
|
|
77
|
+
|
|
78
|
+
for (let i = 0; i < segments.length; i++) {
|
|
79
|
+
const seg = segments[i];
|
|
80
|
+
if (!seg || typeof seg !== 'object') {
|
|
81
|
+
out.push(seg);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (flaggedSegmentIndexes.has(i)) continue;
|
|
85
|
+
|
|
86
|
+
const record = seg as Record<string, unknown>;
|
|
87
|
+
const type = String(record.type ?? '');
|
|
88
|
+
if (type === 'text') {
|
|
89
|
+
const data = asRecord(record.data);
|
|
90
|
+
const text = typeof data.text === 'string' ? data.text : '';
|
|
91
|
+
const localMatches = shiftMatches(options.matches, textCursor, text.length);
|
|
92
|
+
// No spans overall → whole-segment mask; otherwise only mask local hits.
|
|
93
|
+
const nextText = options.matches.length === 0
|
|
94
|
+
? redactText(text, [], options.maskChar)
|
|
95
|
+
: localMatches.length > 0
|
|
96
|
+
? redactText(text, localMatches, options.maskChar)
|
|
97
|
+
: text;
|
|
98
|
+
out.push({
|
|
99
|
+
...record,
|
|
100
|
+
data: { ...data, text: nextText },
|
|
101
|
+
});
|
|
102
|
+
textCursor += text.length;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
out.push(seg);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function shiftMatches(
|
|
113
|
+
matches: readonly TextMatch[],
|
|
114
|
+
offset: number,
|
|
115
|
+
length: number,
|
|
116
|
+
): TextMatch[] {
|
|
117
|
+
const end = offset + length;
|
|
118
|
+
const out: TextMatch[] = [];
|
|
119
|
+
for (const m of matches) {
|
|
120
|
+
if (m.end <= offset || m.start >= end) continue;
|
|
121
|
+
out.push({
|
|
122
|
+
start: Math.max(0, m.start - offset),
|
|
123
|
+
end: Math.min(length, m.end - offset),
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function flaggedImagesFromIndexes(
|
|
130
|
+
images: readonly ExtractedImage[],
|
|
131
|
+
indexes: readonly number[],
|
|
132
|
+
): readonly ExtractedImage[] {
|
|
133
|
+
const set = new Set(indexes);
|
|
134
|
+
return Object.freeze(images.filter((img) => set.has(img.index)));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
138
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
139
|
+
? value as Record<string, unknown>
|
|
140
|
+
: {};
|
|
141
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
export type Severity = 'pass' | 'low' | 'medium' | 'high' | 'critical';
|
|
2
|
+
|
|
3
|
+
export type Action = 'allow' | 'log' | 'reply' | 'redact' | 'drop' | 'recall';
|
|
4
|
+
|
|
5
|
+
export type OnErrorPolicy = 'open' | 'closed';
|
|
6
|
+
|
|
7
|
+
export type Direction = 'inbound' | 'outbound';
|
|
8
|
+
|
|
9
|
+
export interface TextMatch {
|
|
10
|
+
readonly start: number;
|
|
11
|
+
readonly end: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ExtractedImage {
|
|
15
|
+
/** Index among extracted images (for HTTP / flaggedImageIndexes). */
|
|
16
|
+
readonly index: number;
|
|
17
|
+
/** Index in the original segment list when applicable. */
|
|
18
|
+
readonly segmentIndex?: number;
|
|
19
|
+
readonly url?: string;
|
|
20
|
+
readonly base64?: string;
|
|
21
|
+
readonly mime?: string;
|
|
22
|
+
readonly path?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ScanContext {
|
|
26
|
+
readonly adapter: string;
|
|
27
|
+
readonly endpoint: string;
|
|
28
|
+
readonly conversationKind: string;
|
|
29
|
+
readonly conversationId: string;
|
|
30
|
+
readonly sender?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ScanInput {
|
|
34
|
+
readonly text: string;
|
|
35
|
+
readonly images: readonly ExtractedImage[];
|
|
36
|
+
readonly direction: Direction;
|
|
37
|
+
readonly context: ScanContext;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProviderResult {
|
|
41
|
+
readonly sourceId: string;
|
|
42
|
+
readonly severity: Severity;
|
|
43
|
+
readonly matches?: readonly TextMatch[];
|
|
44
|
+
readonly flaggedImageIndexes?: readonly number[];
|
|
45
|
+
readonly reason?: string;
|
|
46
|
+
readonly error?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface MergedResult {
|
|
50
|
+
readonly severity: Severity;
|
|
51
|
+
readonly matches: readonly TextMatch[];
|
|
52
|
+
readonly flaggedImageIndexes: readonly number[];
|
|
53
|
+
readonly sources: readonly ProviderResult[];
|
|
54
|
+
readonly reason?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Graded lexicon item (config `words` entry). */
|
|
58
|
+
export interface LexiconWordConfig {
|
|
59
|
+
readonly word: string;
|
|
60
|
+
/** Per-word severity; falls back to source `defaultSeverity`. */
|
|
61
|
+
readonly severity: Exclude<Severity, 'pass'>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface LocalSourceConfig {
|
|
65
|
+
readonly id: string;
|
|
66
|
+
readonly type: 'local';
|
|
67
|
+
readonly enabled: boolean;
|
|
68
|
+
readonly onError: OnErrorPolicy;
|
|
69
|
+
/** Custom words with per-entry severity. */
|
|
70
|
+
readonly words: readonly LexiconWordConfig[];
|
|
71
|
+
readonly wordFiles: readonly string[];
|
|
72
|
+
/** Merge built-in graded lexicon (default true). */
|
|
73
|
+
readonly includeBuiltin: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Default severity for plain string words / ungraded file lines.
|
|
76
|
+
* (Config key may still be written as `severity` for compatibility.)
|
|
77
|
+
*/
|
|
78
|
+
readonly defaultSeverity: Exclude<Severity, 'pass'>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface HttpSourceConfig {
|
|
82
|
+
readonly id: string;
|
|
83
|
+
readonly type: 'http';
|
|
84
|
+
readonly enabled: boolean;
|
|
85
|
+
readonly onError: OnErrorPolicy;
|
|
86
|
+
readonly url: string;
|
|
87
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
88
|
+
readonly timeoutMs: number;
|
|
89
|
+
readonly forceUpload: boolean;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export type SourceConfig = LocalSourceConfig | HttpSourceConfig;
|
|
93
|
+
|
|
94
|
+
export interface ModerationConfig {
|
|
95
|
+
readonly enabled: boolean;
|
|
96
|
+
readonly onError: OnErrorPolicy;
|
|
97
|
+
readonly maskChar: string;
|
|
98
|
+
readonly replyTemplate: string;
|
|
99
|
+
readonly masters: readonly string[];
|
|
100
|
+
readonly inbound: {
|
|
101
|
+
readonly enabled: boolean;
|
|
102
|
+
readonly bypassMasters: boolean;
|
|
103
|
+
readonly whitelist: {
|
|
104
|
+
readonly userIds: readonly string[];
|
|
105
|
+
readonly conversationIds: readonly string[];
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
readonly outbound: {
|
|
109
|
+
readonly enabled: boolean;
|
|
110
|
+
readonly bypass: boolean;
|
|
111
|
+
};
|
|
112
|
+
readonly actions: Readonly<Record<Severity, readonly Action[]>>;
|
|
113
|
+
readonly sources: readonly SourceConfig[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const SEVERITY_RANK: Readonly<Record<Severity, number>> = Object.freeze({
|
|
117
|
+
pass: 0,
|
|
118
|
+
low: 1,
|
|
119
|
+
medium: 2,
|
|
120
|
+
high: 3,
|
|
121
|
+
critical: 4,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export function maxSeverity(a: Severity, b: Severity): Severity {
|
|
125
|
+
return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function isSeverity(value: unknown): value is Severity {
|
|
129
|
+
return value === 'pass'
|
|
130
|
+
|| value === 'low'
|
|
131
|
+
|| value === 'medium'
|
|
132
|
+
|| value === 'high'
|
|
133
|
+
|| value === 'critical';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function isAction(value: unknown): value is Action {
|
|
137
|
+
return value === 'allow'
|
|
138
|
+
|| value === 'log'
|
|
139
|
+
|| value === 'reply'
|
|
140
|
+
|| value === 'redact'
|
|
141
|
+
|| value === 'drop'
|
|
142
|
+
|| value === 'recall';
|
|
143
|
+
}
|