@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/config.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isAction,
|
|
3
|
+
type Action,
|
|
4
|
+
type HttpSourceConfig,
|
|
5
|
+
type LocalSourceConfig,
|
|
6
|
+
type ModerationConfig,
|
|
7
|
+
type OnErrorPolicy,
|
|
8
|
+
type Severity,
|
|
9
|
+
type SourceConfig,
|
|
10
|
+
} from './types.js';
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_ACTIONS: Readonly<Record<Severity, readonly Action[]>> = Object.freeze({
|
|
13
|
+
pass: Object.freeze(['allow'] as const),
|
|
14
|
+
low: Object.freeze(['log'] as const),
|
|
15
|
+
medium: Object.freeze(['redact'] as const),
|
|
16
|
+
high: Object.freeze(['drop'] as const),
|
|
17
|
+
critical: Object.freeze(['drop', 'recall'] as const),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_MODERATION_CONFIG: ModerationConfig = Object.freeze({
|
|
21
|
+
enabled: true,
|
|
22
|
+
onError: 'open',
|
|
23
|
+
maskChar: '*',
|
|
24
|
+
replyTemplate: '消息含不当内容,已拦截。',
|
|
25
|
+
masters: Object.freeze([] as string[]),
|
|
26
|
+
inbound: Object.freeze({
|
|
27
|
+
enabled: true,
|
|
28
|
+
bypassMasters: true,
|
|
29
|
+
whitelist: Object.freeze({
|
|
30
|
+
userIds: Object.freeze([] as string[]),
|
|
31
|
+
conversationIds: Object.freeze([] as string[]),
|
|
32
|
+
}),
|
|
33
|
+
}),
|
|
34
|
+
outbound: Object.freeze({
|
|
35
|
+
enabled: true,
|
|
36
|
+
bypass: false,
|
|
37
|
+
}),
|
|
38
|
+
actions: DEFAULT_ACTIONS,
|
|
39
|
+
sources: Object.freeze([] as SourceConfig[]),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export function resolveModerationConfig(raw: unknown): ModerationConfig {
|
|
43
|
+
const input = raw && typeof raw === 'object' ? raw as Record<string, unknown> : {};
|
|
44
|
+
const inboundRaw = asRecord(input.inbound);
|
|
45
|
+
const outboundRaw = asRecord(input.outbound);
|
|
46
|
+
const whitelistRaw = asRecord(inboundRaw.whitelist);
|
|
47
|
+
const onError = parseOnError(input.onError, DEFAULT_MODERATION_CONFIG.onError);
|
|
48
|
+
const maskChar = typeof input.maskChar === 'string' && input.maskChar.length > 0
|
|
49
|
+
? input.maskChar.slice(0, 4)
|
|
50
|
+
: DEFAULT_MODERATION_CONFIG.maskChar;
|
|
51
|
+
const replyTemplate = typeof input.replyTemplate === 'string' && input.replyTemplate.length > 0
|
|
52
|
+
? input.replyTemplate
|
|
53
|
+
: DEFAULT_MODERATION_CONFIG.replyTemplate;
|
|
54
|
+
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
enabled: input.enabled !== false,
|
|
57
|
+
onError,
|
|
58
|
+
maskChar,
|
|
59
|
+
replyTemplate,
|
|
60
|
+
masters: freezeStrings(input.masters),
|
|
61
|
+
inbound: Object.freeze({
|
|
62
|
+
enabled: inboundRaw.enabled !== false,
|
|
63
|
+
bypassMasters: inboundRaw.bypassMasters !== false,
|
|
64
|
+
whitelist: Object.freeze({
|
|
65
|
+
userIds: freezeStrings(whitelistRaw.userIds),
|
|
66
|
+
conversationIds: freezeStrings(whitelistRaw.conversationIds),
|
|
67
|
+
}),
|
|
68
|
+
}),
|
|
69
|
+
outbound: Object.freeze({
|
|
70
|
+
enabled: outboundRaw.enabled !== false,
|
|
71
|
+
bypass: outboundRaw.bypass === true,
|
|
72
|
+
}),
|
|
73
|
+
actions: resolveActions(input.actions),
|
|
74
|
+
sources: Object.freeze(
|
|
75
|
+
(Array.isArray(input.sources) ? input.sources : [])
|
|
76
|
+
.map((item) => parseSource(item, onError))
|
|
77
|
+
.filter((item): item is SourceConfig => item != null),
|
|
78
|
+
),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function resolveActions(raw: unknown): Readonly<Record<Severity, readonly Action[]>> {
|
|
83
|
+
const input = asRecord(raw);
|
|
84
|
+
return Object.freeze({
|
|
85
|
+
pass: resolveActionList(input.pass, DEFAULT_ACTIONS.pass),
|
|
86
|
+
low: resolveActionList(input.low, DEFAULT_ACTIONS.low),
|
|
87
|
+
medium: resolveActionList(input.medium, DEFAULT_ACTIONS.medium),
|
|
88
|
+
high: resolveActionList(input.high, DEFAULT_ACTIONS.high),
|
|
89
|
+
critical: resolveActionList(input.critical, DEFAULT_ACTIONS.critical),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resolveActionList(raw: unknown, fallback: readonly Action[]): readonly Action[] {
|
|
94
|
+
if (typeof raw === 'string' && isAction(raw)) return Object.freeze([raw]);
|
|
95
|
+
if (Array.isArray(raw)) {
|
|
96
|
+
const actions = raw.filter(isAction);
|
|
97
|
+
if (actions.length > 0) return Object.freeze([...actions]);
|
|
98
|
+
}
|
|
99
|
+
return fallback;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function parseSource(raw: unknown, globalOnError: OnErrorPolicy): SourceConfig | null {
|
|
103
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
104
|
+
const input = raw as Record<string, unknown>;
|
|
105
|
+
const id = typeof input.id === 'string' ? input.id.trim() : '';
|
|
106
|
+
if (!id) return null;
|
|
107
|
+
const enabled = input.enabled !== false;
|
|
108
|
+
const onError = parseOnError(input.onError, globalOnError);
|
|
109
|
+
|
|
110
|
+
if (input.type === 'local') {
|
|
111
|
+
const defaultSeverity = parseHitSeverity(input.defaultSeverity ?? input.severity);
|
|
112
|
+
const local: LocalSourceConfig = Object.freeze({
|
|
113
|
+
id,
|
|
114
|
+
type: 'local',
|
|
115
|
+
enabled,
|
|
116
|
+
onError,
|
|
117
|
+
words: freezeLexiconWords(input.words, defaultSeverity),
|
|
118
|
+
wordFiles: freezeStrings(input.wordFiles),
|
|
119
|
+
includeBuiltin: input.includeBuiltin !== false,
|
|
120
|
+
defaultSeverity,
|
|
121
|
+
});
|
|
122
|
+
return local;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (input.type === 'http') {
|
|
126
|
+
const url = typeof input.url === 'string' ? input.url.trim() : '';
|
|
127
|
+
if (!url) return null;
|
|
128
|
+
const headersRaw = asRecord(input.headers);
|
|
129
|
+
const headers: Record<string, string> = {};
|
|
130
|
+
for (const [key, value] of Object.entries(headersRaw)) {
|
|
131
|
+
if (typeof value === 'string') headers[key] = value;
|
|
132
|
+
}
|
|
133
|
+
const timeoutMs = clampNumber(input.timeoutMs, 5_000, 500, 60_000);
|
|
134
|
+
const http: HttpSourceConfig = Object.freeze({
|
|
135
|
+
id,
|
|
136
|
+
type: 'http',
|
|
137
|
+
enabled,
|
|
138
|
+
onError,
|
|
139
|
+
url,
|
|
140
|
+
headers: Object.freeze(headers),
|
|
141
|
+
timeoutMs,
|
|
142
|
+
forceUpload: input.forceUpload === true,
|
|
143
|
+
});
|
|
144
|
+
return http;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function parseHitSeverity(raw: unknown): Exclude<Severity, 'pass'> {
|
|
151
|
+
if (raw === 'low' || raw === 'medium' || raw === 'high' || raw === 'critical') return raw;
|
|
152
|
+
return 'high';
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseOnError(raw: unknown, fallback: OnErrorPolicy): OnErrorPolicy {
|
|
156
|
+
return raw === 'open' || raw === 'closed' ? raw : fallback;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
160
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
161
|
+
? value as Record<string, unknown>
|
|
162
|
+
: {};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function freezeStrings(value: unknown): readonly string[] {
|
|
166
|
+
if (!Array.isArray(value)) return Object.freeze([]);
|
|
167
|
+
return Object.freeze(
|
|
168
|
+
value.filter((item): item is string => typeof item === 'string' && item.length > 0),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function freezeLexiconWords(
|
|
173
|
+
value: unknown,
|
|
174
|
+
defaultSeverity: Exclude<Severity, 'pass'>,
|
|
175
|
+
): LocalSourceConfig['words'] {
|
|
176
|
+
if (!Array.isArray(value)) return Object.freeze([]);
|
|
177
|
+
const out: LocalSourceConfig['words'][number][] = [];
|
|
178
|
+
for (const item of value) {
|
|
179
|
+
if (typeof item === 'string') {
|
|
180
|
+
const word = item.trim();
|
|
181
|
+
if (word) out.push(Object.freeze({ word, severity: defaultSeverity }));
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (!item || typeof item !== 'object') continue;
|
|
185
|
+
const record = item as Record<string, unknown>;
|
|
186
|
+
const word = typeof record.word === 'string'
|
|
187
|
+
? record.word.trim()
|
|
188
|
+
: typeof record.text === 'string'
|
|
189
|
+
? record.text.trim()
|
|
190
|
+
: '';
|
|
191
|
+
if (!word) continue;
|
|
192
|
+
out.push(Object.freeze({
|
|
193
|
+
word,
|
|
194
|
+
severity: parseHitSeverity(record.severity ?? defaultSeverity),
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
return Object.freeze(out);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function clampNumber(value: unknown, fallback: number, min: number, max: number): number {
|
|
201
|
+
const n = typeof value === 'number' && Number.isFinite(value) ? value : fallback;
|
|
202
|
+
return Math.min(max, Math.max(min, Math.trunc(n)));
|
|
203
|
+
}
|
package/src/engine.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { formatCompact, getLogger, type Logger } from '@zhin.js/logger';
|
|
2
|
+
import { resolveModerationConfig } from './config.js';
|
|
3
|
+
import { mergeMatches, type ExtractedContent } from './extract.js';
|
|
4
|
+
import { redactOutboundPayload } from './redact.js';
|
|
5
|
+
import { createProviders } from './providers/registry.js';
|
|
6
|
+
import type { ModerationProvider } from './providers/types.js';
|
|
7
|
+
import {
|
|
8
|
+
maxSeverity,
|
|
9
|
+
type Action,
|
|
10
|
+
type Direction,
|
|
11
|
+
type MergedResult,
|
|
12
|
+
type ModerationConfig,
|
|
13
|
+
type ProviderResult,
|
|
14
|
+
type ScanInput,
|
|
15
|
+
type Severity,
|
|
16
|
+
} from './types.js';
|
|
17
|
+
|
|
18
|
+
export interface ApplyHooks {
|
|
19
|
+
readonly reply?: (content: string) => Promise<void>;
|
|
20
|
+
readonly recall?: () => Promise<boolean>;
|
|
21
|
+
readonly replacePayload?: (payload: unknown) => void;
|
|
22
|
+
readonly getPayload?: () => unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ApplyResult {
|
|
26
|
+
readonly continue: boolean;
|
|
27
|
+
readonly severity: Severity;
|
|
28
|
+
readonly actions: readonly Action[];
|
|
29
|
+
readonly merged: MergedResult;
|
|
30
|
+
readonly redactedPayload?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class ModerationEngine {
|
|
34
|
+
readonly #logger: Logger;
|
|
35
|
+
readonly #cwd: string;
|
|
36
|
+
readonly #fetch?: typeof fetch;
|
|
37
|
+
#providers: readonly ModerationProvider[] = Object.freeze([]);
|
|
38
|
+
#config: ModerationConfig = resolveModerationConfig({});
|
|
39
|
+
|
|
40
|
+
constructor(options: {
|
|
41
|
+
readonly logger?: Logger;
|
|
42
|
+
readonly cwd?: string;
|
|
43
|
+
readonly fetch?: typeof fetch;
|
|
44
|
+
} = {}) {
|
|
45
|
+
this.#logger = options.logger ?? getLogger('content-moderation');
|
|
46
|
+
this.#cwd = options.cwd ?? process.cwd();
|
|
47
|
+
this.#fetch = options.fetch;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get config(): ModerationConfig {
|
|
51
|
+
return this.#config;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
configure(raw: unknown): void {
|
|
55
|
+
this.#config = resolveModerationConfig(raw);
|
|
56
|
+
this.#providers = createProviders(this.#config, {
|
|
57
|
+
cwd: this.#cwd,
|
|
58
|
+
fetch: this.#fetch,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Test helper: inject providers without going through config sources. */
|
|
63
|
+
setProvidersForTest(providers: readonly ModerationProvider[]): void {
|
|
64
|
+
this.#providers = Object.freeze([...providers]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async scan(input: ScanInput): Promise<MergedResult> {
|
|
68
|
+
if (this.#providers.length === 0) {
|
|
69
|
+
return Object.freeze({
|
|
70
|
+
severity: 'pass',
|
|
71
|
+
matches: Object.freeze([]),
|
|
72
|
+
flaggedImageIndexes: Object.freeze([]),
|
|
73
|
+
sources: Object.freeze([]),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const results = await Promise.all(
|
|
78
|
+
this.#providers.map(async (provider) => {
|
|
79
|
+
try {
|
|
80
|
+
return await provider.scan(input);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
return Object.freeze({
|
|
83
|
+
sourceId: provider.id,
|
|
84
|
+
severity: 'pass' as const,
|
|
85
|
+
error: true,
|
|
86
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
87
|
+
}) satisfies ProviderResult;
|
|
88
|
+
}
|
|
89
|
+
}),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
return mergeResults(results);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async apply(options: {
|
|
96
|
+
readonly direction: Direction;
|
|
97
|
+
readonly extracted: ExtractedContent;
|
|
98
|
+
readonly scanInput: ScanInput;
|
|
99
|
+
readonly hooks: ApplyHooks;
|
|
100
|
+
}): Promise<ApplyResult> {
|
|
101
|
+
const merged = await this.scan(options.scanInput);
|
|
102
|
+
const actions = [...this.#config.actions[merged.severity]];
|
|
103
|
+
const actionSet = new Set<Action>(actions);
|
|
104
|
+
|
|
105
|
+
if (merged.severity !== 'pass' || actionSet.has('log')) {
|
|
106
|
+
this.#logger.info(formatCompact({
|
|
107
|
+
op: 'moderation',
|
|
108
|
+
direction: options.direction,
|
|
109
|
+
severity: merged.severity,
|
|
110
|
+
actions: actions.join(','),
|
|
111
|
+
sources: merged.sources.map((s) => `${s.sourceId}:${s.severity}${s.error ? '!' : ''}`).join('|'),
|
|
112
|
+
conversation: options.scanInput.context.conversationId,
|
|
113
|
+
sender: options.scanInput.context.sender,
|
|
114
|
+
reason: merged.reason,
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (actionSet.has('log') && merged.severity === 'pass') {
|
|
119
|
+
// already logged above when log is present
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let redactedPayload: unknown | undefined;
|
|
123
|
+
|
|
124
|
+
if (actionSet.has('redact')) {
|
|
125
|
+
if (options.direction === 'outbound') {
|
|
126
|
+
const payload = options.hooks.getPayload?.();
|
|
127
|
+
redactedPayload = redactOutboundPayload(payload, options.extracted, {
|
|
128
|
+
maskChar: this.#config.maskChar,
|
|
129
|
+
matches: merged.matches,
|
|
130
|
+
flaggedImageIndexes: merged.flaggedImageIndexes,
|
|
131
|
+
});
|
|
132
|
+
options.hooks.replacePayload?.(redactedPayload);
|
|
133
|
+
}
|
|
134
|
+
// inbound redact:Message 只读,无法改写原文;仍 next() 放行
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (actionSet.has('reply') && options.direction === 'inbound') {
|
|
138
|
+
try {
|
|
139
|
+
await options.hooks.reply?.(this.#config.replyTemplate);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
this.#logger.warn(formatCompact({
|
|
142
|
+
op: 'moderation_reply_failed',
|
|
143
|
+
error: error instanceof Error ? error.message : String(error),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
} else if (actionSet.has('reply') && options.direction === 'outbound') {
|
|
147
|
+
this.#logger.debug(formatCompact({
|
|
148
|
+
op: 'moderation_reply_skipped',
|
|
149
|
+
direction: 'outbound',
|
|
150
|
+
}));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (actionSet.has('recall') && options.direction === 'inbound') {
|
|
154
|
+
try {
|
|
155
|
+
const ok = await options.hooks.recall?.();
|
|
156
|
+
if (!ok) {
|
|
157
|
+
this.#logger.warn(formatCompact({
|
|
158
|
+
op: 'moderation_recall_degraded',
|
|
159
|
+
reason: 'unsupported_or_missing_id',
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
} catch (error) {
|
|
163
|
+
this.#logger.warn(formatCompact({
|
|
164
|
+
op: 'moderation_recall_degraded',
|
|
165
|
+
error: error instanceof Error ? error.message : String(error),
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const stop = shouldStop(options.direction, actionSet);
|
|
171
|
+
return Object.freeze({
|
|
172
|
+
continue: !stop,
|
|
173
|
+
severity: merged.severity,
|
|
174
|
+
actions: Object.freeze(actions),
|
|
175
|
+
merged,
|
|
176
|
+
...(redactedPayload !== undefined ? { redactedPayload } : {}),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function mergeResults(results: readonly ProviderResult[]): MergedResult {
|
|
182
|
+
let severity: Severity = 'pass';
|
|
183
|
+
const matches = mergeMatches(...results.map((r) => r.matches));
|
|
184
|
+
const flagged = new Set<number>();
|
|
185
|
+
const reasons: string[] = [];
|
|
186
|
+
|
|
187
|
+
for (const result of results) {
|
|
188
|
+
severity = maxSeverity(severity, result.severity);
|
|
189
|
+
for (const idx of result.flaggedImageIndexes ?? []) flagged.add(idx);
|
|
190
|
+
if (result.reason) reasons.push(`${result.sourceId}: ${result.reason}`);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return Object.freeze({
|
|
194
|
+
severity,
|
|
195
|
+
matches,
|
|
196
|
+
flaggedImageIndexes: Object.freeze([...flagged].sort((a, b) => a - b)),
|
|
197
|
+
sources: Object.freeze([...results]),
|
|
198
|
+
...(reasons.length ? { reason: reasons.join('; ') } : {}),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function shouldStop(direction: Direction, actions: ReadonlySet<Action>): boolean {
|
|
203
|
+
if (actions.has('drop')) return true;
|
|
204
|
+
// 入站 recall:尽量撤回后不再进入后续处理(无 allow 时中断)
|
|
205
|
+
if (direction === 'inbound' && actions.has('recall') && !actions.has('allow')) {
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
let sharedEngine: ModerationEngine | null = null;
|
|
212
|
+
|
|
213
|
+
export function getModerationEngine(): ModerationEngine {
|
|
214
|
+
if (!sharedEngine) sharedEngine = new ModerationEngine();
|
|
215
|
+
return sharedEngine;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function resetModerationEngine(): void {
|
|
219
|
+
sharedEngine = null;
|
|
220
|
+
}
|
package/src/extract.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ExtractedImage, ScanContext, TextMatch } from './types.js';
|
|
2
|
+
|
|
3
|
+
export interface ExtractedContent {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly images: readonly ExtractedImage[];
|
|
6
|
+
/** Original segment list when payload was structured; undefined for plain string. */
|
|
7
|
+
readonly segments?: readonly Record<string, unknown>[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function extractFromTextAndSegments(
|
|
11
|
+
content: string,
|
|
12
|
+
segments: readonly unknown[] | undefined,
|
|
13
|
+
): ExtractedContent {
|
|
14
|
+
if (Array.isArray(segments) && segments.length > 0) {
|
|
15
|
+
return extractFromSegments(segments);
|
|
16
|
+
}
|
|
17
|
+
return Object.freeze({
|
|
18
|
+
text: content ?? '',
|
|
19
|
+
images: Object.freeze([] as ExtractedImage[]),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractFromOutboundPayload(payload: unknown): ExtractedContent {
|
|
24
|
+
if (typeof payload === 'string') {
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
text: payload,
|
|
27
|
+
images: Object.freeze([] as ExtractedImage[]),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (Array.isArray(payload)) {
|
|
31
|
+
return extractFromSegments(payload);
|
|
32
|
+
}
|
|
33
|
+
if (payload && typeof payload === 'object' && 'type' in payload) {
|
|
34
|
+
return extractFromSegments([payload]);
|
|
35
|
+
}
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
text: payload == null ? '' : String(payload),
|
|
38
|
+
images: Object.freeze([] as ExtractedImage[]),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function extractFromSegments(segments: readonly unknown[]): ExtractedContent {
|
|
43
|
+
const texts: string[] = [];
|
|
44
|
+
const images: ExtractedImage[] = [];
|
|
45
|
+
const normalized: Record<string, unknown>[] = [];
|
|
46
|
+
|
|
47
|
+
for (let i = 0; i < segments.length; i++) {
|
|
48
|
+
const seg = segments[i];
|
|
49
|
+
if (!seg || typeof seg !== 'object') continue;
|
|
50
|
+
const record = seg as Record<string, unknown>;
|
|
51
|
+
normalized.push(record);
|
|
52
|
+
const type = String(record.type ?? '');
|
|
53
|
+
const data = asRecord(record.data);
|
|
54
|
+
|
|
55
|
+
if (type === 'text') {
|
|
56
|
+
const text = typeof data.text === 'string' ? data.text : '';
|
|
57
|
+
if (text) texts.push(text);
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (type === 'image') {
|
|
62
|
+
const media = asRecord(data.media);
|
|
63
|
+
const image = mediaToImage(images.length, i, media, data);
|
|
64
|
+
if (image) images.push(image);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Object.freeze({
|
|
69
|
+
text: texts.join(''),
|
|
70
|
+
images: Object.freeze(images),
|
|
71
|
+
segments: Object.freeze(normalized),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mediaToImage(
|
|
76
|
+
index: number,
|
|
77
|
+
segmentIndex: number,
|
|
78
|
+
media: Record<string, unknown>,
|
|
79
|
+
data: Record<string, unknown>,
|
|
80
|
+
): ExtractedImage | null {
|
|
81
|
+
// Canonical MediaRef
|
|
82
|
+
if (typeof media.kind === 'string' && typeof media.value === 'string') {
|
|
83
|
+
const mime = typeof media.mime_type === 'string' ? media.mime_type : undefined;
|
|
84
|
+
if (media.kind === 'url') {
|
|
85
|
+
return Object.freeze({ index, segmentIndex, url: media.value, mime });
|
|
86
|
+
}
|
|
87
|
+
if (media.kind === 'base64') {
|
|
88
|
+
return Object.freeze({ index, segmentIndex, base64: media.value, mime });
|
|
89
|
+
}
|
|
90
|
+
if (media.kind === 'path') {
|
|
91
|
+
return Object.freeze({ index, segmentIndex, path: media.value, mime });
|
|
92
|
+
}
|
|
93
|
+
// kind=file: opaque platform ref — no fetchable URL
|
|
94
|
+
return Object.freeze({ index, segmentIndex, mime });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Legacy wire fields
|
|
98
|
+
if (typeof data.url === 'string' && data.url) {
|
|
99
|
+
return Object.freeze({ index, segmentIndex, url: data.url });
|
|
100
|
+
}
|
|
101
|
+
if (typeof data.base64 === 'string' && data.base64) {
|
|
102
|
+
return Object.freeze({ index, segmentIndex, base64: data.base64 });
|
|
103
|
+
}
|
|
104
|
+
if (typeof data.file === 'string' && data.file) {
|
|
105
|
+
const file = data.file;
|
|
106
|
+
if (/^https?:\/\//i.test(file)) {
|
|
107
|
+
return Object.freeze({ index, segmentIndex, url: file });
|
|
108
|
+
}
|
|
109
|
+
return Object.freeze({ index, segmentIndex, path: file });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function buildScanContext(input: {
|
|
116
|
+
readonly adapter?: string;
|
|
117
|
+
readonly endpoint?: string;
|
|
118
|
+
readonly conversationKind?: string;
|
|
119
|
+
readonly conversationId?: string;
|
|
120
|
+
readonly sender?: string;
|
|
121
|
+
}): ScanContext {
|
|
122
|
+
return Object.freeze({
|
|
123
|
+
adapter: input.adapter ?? '',
|
|
124
|
+
endpoint: input.endpoint ?? '',
|
|
125
|
+
conversationKind: input.conversationKind ?? '',
|
|
126
|
+
conversationId: input.conversationId ?? '',
|
|
127
|
+
...(input.sender != null ? { sender: input.sender } : {}),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function mergeMatches(
|
|
132
|
+
...lists: Array<readonly TextMatch[] | undefined>
|
|
133
|
+
): readonly TextMatch[] {
|
|
134
|
+
const out: TextMatch[] = [];
|
|
135
|
+
for (const list of lists) {
|
|
136
|
+
if (!list) continue;
|
|
137
|
+
for (const m of list) {
|
|
138
|
+
if (
|
|
139
|
+
Number.isFinite(m.start)
|
|
140
|
+
&& Number.isFinite(m.end)
|
|
141
|
+
&& m.end > m.start
|
|
142
|
+
&& m.start >= 0
|
|
143
|
+
) {
|
|
144
|
+
out.push({ start: Math.trunc(m.start), end: Math.trunc(m.end) });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Object.freeze(out);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
152
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
153
|
+
? value as Record<string, unknown>
|
|
154
|
+
: {};
|
|
155
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DEFAULT_ACTIONS,
|
|
3
|
+
DEFAULT_MODERATION_CONFIG,
|
|
4
|
+
resolveModerationConfig,
|
|
5
|
+
} from './config.js';
|
|
6
|
+
export {
|
|
7
|
+
shouldBypassInbound,
|
|
8
|
+
shouldBypassOutbound,
|
|
9
|
+
} from './bypass.js';
|
|
10
|
+
export {
|
|
11
|
+
extractFromOutboundPayload,
|
|
12
|
+
extractFromTextAndSegments,
|
|
13
|
+
buildScanContext,
|
|
14
|
+
} from './extract.js';
|
|
15
|
+
export {
|
|
16
|
+
redactText,
|
|
17
|
+
redactOutboundPayload,
|
|
18
|
+
} from './redact.js';
|
|
19
|
+
export {
|
|
20
|
+
ModerationEngine,
|
|
21
|
+
getModerationEngine,
|
|
22
|
+
resetModerationEngine,
|
|
23
|
+
mergeResults,
|
|
24
|
+
} from './engine.js';
|
|
25
|
+
export type {
|
|
26
|
+
Severity,
|
|
27
|
+
Action,
|
|
28
|
+
OnErrorPolicy,
|
|
29
|
+
Direction,
|
|
30
|
+
ModerationConfig,
|
|
31
|
+
ProviderResult,
|
|
32
|
+
MergedResult,
|
|
33
|
+
ScanInput,
|
|
34
|
+
SourceConfig,
|
|
35
|
+
} from './types.js';
|
|
36
|
+
export { maxSeverity, SEVERITY_RANK } from './types.js';
|
|
37
|
+
export {
|
|
38
|
+
LocalLexiconProvider,
|
|
39
|
+
findMatches,
|
|
40
|
+
findGradedMatches,
|
|
41
|
+
loadWords,
|
|
42
|
+
loadLexiconEntries,
|
|
43
|
+
parseWordFile,
|
|
44
|
+
parseWordLine,
|
|
45
|
+
} from './providers/local-lexicon.js';
|
|
46
|
+
export { BUILTIN_LEXICON, mergeLexiconEntries } from './providers/builtin-lexicon.js';
|
|
47
|
+
export type { LexiconEntry, LexiconSeverity } from './providers/builtin-lexicon.js';
|
|
48
|
+
export { HttpModerationProvider, parseHttpResult, isPublicHttpUrl } from './providers/http.js';
|
|
49
|
+
export { createProviders } from './providers/registry.js';
|