@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.
Files changed (68) hide show
  1. package/README.md +173 -0
  2. package/lib/bypass.d.ts +10 -0
  3. package/lib/bypass.d.ts.map +1 -0
  4. package/lib/bypass.js +27 -0
  5. package/lib/bypass.js.map +1 -0
  6. package/lib/config.d.ts +5 -0
  7. package/lib/config.d.ts.map +1 -0
  8. package/lib/config.js +184 -0
  9. package/lib/config.js.map +1 -0
  10. package/lib/engine.d.ts +40 -0
  11. package/lib/engine.d.ts.map +1 -0
  12. package/lib/engine.js +170 -0
  13. package/lib/engine.js.map +1 -0
  14. package/lib/extract.d.ts +18 -0
  15. package/lib/extract.d.ts.map +1 -0
  16. package/lib/extract.js +121 -0
  17. package/lib/extract.js.map +1 -0
  18. package/lib/index.d.ts +13 -0
  19. package/lib/index.d.ts.map +1 -0
  20. package/lib/index.js +11 -0
  21. package/lib/index.js.map +1 -0
  22. package/lib/providers/builtin-lexicon.d.ts +15 -0
  23. package/lib/providers/builtin-lexicon.d.ts.map +1 -0
  24. package/lib/providers/builtin-lexicon.js +133 -0
  25. package/lib/providers/builtin-lexicon.js.map +1 -0
  26. package/lib/providers/http.d.ts +14 -0
  27. package/lib/providers/http.d.ts.map +1 -0
  28. package/lib/providers/http.js +191 -0
  29. package/lib/providers/http.js.map +1 -0
  30. package/lib/providers/local-lexicon.d.ts +28 -0
  31. package/lib/providers/local-lexicon.d.ts.map +1 -0
  32. package/lib/providers/local-lexicon.js +129 -0
  33. package/lib/providers/local-lexicon.js.map +1 -0
  34. package/lib/providers/registry.d.ts +8 -0
  35. package/lib/providers/registry.d.ts.map +1 -0
  36. package/lib/providers/registry.js +24 -0
  37. package/lib/providers/registry.js.map +1 -0
  38. package/lib/providers/types.d.ts +6 -0
  39. package/lib/providers/types.d.ts.map +1 -0
  40. package/lib/providers/types.js +2 -0
  41. package/lib/providers/types.js.map +1 -0
  42. package/lib/redact.d.ts +18 -0
  43. package/lib/redact.d.ts.map +1 -0
  44. package/lib/redact.js +102 -0
  45. package/lib/redact.js.map +1 -0
  46. package/lib/types.d.ts +105 -0
  47. package/lib/types.d.ts.map +1 -0
  48. package/lib/types.js +26 -0
  49. package/lib/types.js.map +1 -0
  50. package/middlewares/inbound.js +97 -0
  51. package/middlewares/inbound.ts +112 -0
  52. package/middlewares/outbound.js +53 -0
  53. package/middlewares/outbound.ts +66 -0
  54. package/package.json +81 -0
  55. package/plugin.js +8 -0
  56. package/schema.json +240 -0
  57. package/src/bypass.ts +38 -0
  58. package/src/config.ts +203 -0
  59. package/src/engine.ts +220 -0
  60. package/src/extract.ts +155 -0
  61. package/src/index.ts +49 -0
  62. package/src/providers/builtin-lexicon.ts +149 -0
  63. package/src/providers/http.ts +225 -0
  64. package/src/providers/local-lexicon.ts +148 -0
  65. package/src/providers/registry.ts +37 -0
  66. package/src/providers/types.ts +6 -0
  67. package/src/redact.ts +141 -0
  68. package/src/types.ts +143 -0
@@ -0,0 +1,112 @@
1
+ import { defineMiddleware } from '@zhin.js/middleware';
2
+ import type { Message } from '@zhin.js/core/runtime';
3
+ import { outboundHostToken } from '@zhin.js/plugin-runtime';
4
+ import {
5
+ buildScanContext,
6
+ extractFromTextAndSegments,
7
+ getModerationEngine,
8
+ resolveModerationConfig,
9
+ shouldBypassInbound,
10
+ type ModerationConfig,
11
+ } from '../src/index.js';
12
+
13
+ /**
14
+ * Inbound content moderation.
15
+ * - order -100: run early before commands / other middleware
16
+ * - inbound redact:无法改写只读 Message,仍 next() 放行
17
+ */
18
+ export default defineMiddleware<Message, ModerationConfig>({
19
+ target: 'inbound',
20
+ phase: 'before-dispatch',
21
+ order: -100,
22
+ async handle(context, next) {
23
+ const config = resolveModerationConfig(context.config);
24
+ if (!config.enabled || !config.inbound.enabled) {
25
+ await next();
26
+ return;
27
+ }
28
+
29
+ const message = context.input;
30
+ const sender = message.sender;
31
+ const conversationId = message.conversation.id;
32
+ const endpointMasters = resolveEndpointMasters(message, config);
33
+
34
+ if (shouldBypassInbound(config, {
35
+ sender,
36
+ conversationId,
37
+ endpointMasters,
38
+ })) {
39
+ await next();
40
+ return;
41
+ }
42
+
43
+ const engine = getModerationEngine();
44
+ engine.configure(config);
45
+
46
+ const extracted = extractFromTextAndSegments(message.content, message.segments);
47
+ if (!extracted.text && extracted.images.length === 0) {
48
+ await next();
49
+ return;
50
+ }
51
+
52
+ const scanInput = {
53
+ text: extracted.text,
54
+ images: extracted.images,
55
+ direction: 'inbound' as const,
56
+ context: buildScanContext({
57
+ adapter: message.conversation.endpoint.adapter,
58
+ endpoint: message.conversation.endpoint.id,
59
+ conversationKind: message.conversation.kind,
60
+ conversationId,
61
+ sender,
62
+ }),
63
+ };
64
+
65
+ const result = await engine.apply({
66
+ direction: 'inbound',
67
+ extracted,
68
+ scanInput,
69
+ hooks: {
70
+ reply: async (content) => {
71
+ await message.$reply(content);
72
+ },
73
+ recall: async () => {
74
+ const messageId = message.id;
75
+ if (!messageId) return false;
76
+ try {
77
+ const host = context.use(outboundHostToken);
78
+ if (!host.recall) return false;
79
+ await host.recall({
80
+ adapter: message.conversation.endpoint.adapter,
81
+ endpointId: message.conversation.endpoint.id,
82
+ messageId,
83
+ });
84
+ return true;
85
+ } catch {
86
+ return false;
87
+ }
88
+ },
89
+ },
90
+ });
91
+
92
+ if (result.continue) {
93
+ await next();
94
+ }
95
+ },
96
+ });
97
+
98
+ /**
99
+ * Masters from plugin config + optional metadata.endpointMaster / metadata.master.
100
+ */
101
+ function resolveEndpointMasters(
102
+ message: Message,
103
+ config: ModerationConfig,
104
+ ): readonly string[] {
105
+ const out: string[] = [...config.masters];
106
+ const meta = message.metadata ?? {};
107
+ for (const key of ['master', 'endpointMaster', 'owner'] as const) {
108
+ const value = meta[key];
109
+ if (typeof value === 'string' && value.trim()) out.push(value.trim());
110
+ }
111
+ return Object.freeze(out);
112
+ }
@@ -0,0 +1,53 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { defineMiddleware } from '@zhin.js/middleware';
3
+ import { buildScanContext, extractFromOutboundPayload, getModerationEngine, resolveModerationConfig, shouldBypassOutbound, } from "../lib/index.js";
4
+ /**
5
+ * Outbound content moderation.
6
+ * - redact uses envelope.replace()
7
+ * - drop skips platform send (do not call next)
8
+ */
9
+ export default defineMiddleware({
10
+ target: 'outbound',
11
+ phase: 'before-dispatch',
12
+ order: -100,
13
+ async handle(context, next) {
14
+ const config = resolveModerationConfig(context.config);
15
+ if (shouldBypassOutbound(config)) {
16
+ await next();
17
+ return;
18
+ }
19
+ const envelope = context.input;
20
+ const engine = getModerationEngine();
21
+ engine.configure(config);
22
+ const extracted = extractFromOutboundPayload(envelope.payload);
23
+ if (!extracted.text && extracted.images.length === 0) {
24
+ await next();
25
+ return;
26
+ }
27
+ const scanInput = {
28
+ text: extracted.text,
29
+ images: extracted.images,
30
+ direction: 'outbound',
31
+ context: buildScanContext({
32
+ adapter: envelope.conversation.endpoint.adapter,
33
+ endpoint: envelope.conversation.endpoint.id,
34
+ conversationKind: envelope.conversation.kind,
35
+ conversationId: envelope.conversation.id,
36
+ }),
37
+ };
38
+ const result = await engine.apply({
39
+ direction: 'outbound',
40
+ extracted,
41
+ scanInput,
42
+ hooks: {
43
+ getPayload: () => envelope.payload,
44
+ replacePayload: (payload) => {
45
+ envelope.replace(payload);
46
+ },
47
+ },
48
+ });
49
+ if (result.continue) {
50
+ await next();
51
+ }
52
+ },
53
+ });
@@ -0,0 +1,66 @@
1
+ import { defineMiddleware } from '@zhin.js/middleware';
2
+ import type { OutboundEnvelope } from '@zhin.js/core/runtime';
3
+ import {
4
+ buildScanContext,
5
+ extractFromOutboundPayload,
6
+ getModerationEngine,
7
+ resolveModerationConfig,
8
+ shouldBypassOutbound,
9
+ type ModerationConfig,
10
+ } from '../src/index.js';
11
+
12
+ /**
13
+ * Outbound content moderation.
14
+ * - redact uses envelope.replace()
15
+ * - drop skips platform send (do not call next)
16
+ */
17
+ export default defineMiddleware<OutboundEnvelope, ModerationConfig>({
18
+ target: 'outbound',
19
+ phase: 'before-dispatch',
20
+ order: -100,
21
+ async handle(context, next) {
22
+ const config = resolveModerationConfig(context.config);
23
+ if (shouldBypassOutbound(config)) {
24
+ await next();
25
+ return;
26
+ }
27
+
28
+ const envelope = context.input;
29
+ const engine = getModerationEngine();
30
+ engine.configure(config);
31
+
32
+ const extracted = extractFromOutboundPayload(envelope.payload);
33
+ if (!extracted.text && extracted.images.length === 0) {
34
+ await next();
35
+ return;
36
+ }
37
+
38
+ const scanInput = {
39
+ text: extracted.text,
40
+ images: extracted.images,
41
+ direction: 'outbound' as const,
42
+ context: buildScanContext({
43
+ adapter: envelope.conversation.endpoint.adapter,
44
+ endpoint: envelope.conversation.endpoint.id,
45
+ conversationKind: envelope.conversation.kind,
46
+ conversationId: envelope.conversation.id,
47
+ }),
48
+ };
49
+
50
+ const result = await engine.apply({
51
+ direction: 'outbound',
52
+ extracted,
53
+ scanInput,
54
+ hooks: {
55
+ getPayload: () => envelope.payload,
56
+ replacePayload: (payload) => {
57
+ envelope.replace(payload);
58
+ },
59
+ },
60
+ });
61
+
62
+ if (result.continue) {
63
+ await next();
64
+ }
65
+ },
66
+ });
package/package.json ADDED
@@ -0,0 +1,81 @@
1
+ {
2
+ "name": "@zhin.js/plugin-content-moderation",
3
+ "version": "1.0.0",
4
+ "description": "Content moderation plugin for Zhin.js — inbound/outbound multi-source scanning (Plugin Runtime)",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "development": "./src/index.ts",
12
+ "import": "./lib/index.js"
13
+ },
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "plugin.js",
18
+ "schema.json",
19
+ "middlewares",
20
+ "src",
21
+ "lib",
22
+ "README.md"
23
+ ],
24
+ "keywords": [
25
+ "zhin",
26
+ "zhin.js",
27
+ "bot",
28
+ "plugin",
29
+ "moderation",
30
+ "content-safety",
31
+ "chatbot",
32
+ "im"
33
+ ],
34
+ "author": {
35
+ "name": "lc-cn",
36
+ "email": "admin@liucl.cn",
37
+ "url": "https://github.com/lc-cn"
38
+ },
39
+ "license": "MIT",
40
+ "dependencies": {
41
+ "@zhin.js/core": "1.5.2",
42
+ "@zhin.js/logger": "1.0.75",
43
+ "@zhin.js/middleware": "1.0.6",
44
+ "@zhin.js/plugin-runtime": "1.1.3"
45
+ },
46
+ "devDependencies": {
47
+ "typescript": "^6.0.3",
48
+ "vitest": "^4.1.10"
49
+ },
50
+ "repository": {
51
+ "type": "git",
52
+ "url": "git+https://github.com/zhinjs/zhin.git",
53
+ "directory": "plugins/utils/content-moderation"
54
+ },
55
+ "publishConfig": {
56
+ "access": "public",
57
+ "registry": "https://registry.npmjs.org"
58
+ },
59
+ "engines": {
60
+ "node": "^20.19.0 || >=22.12.0"
61
+ },
62
+ "zhin": {
63
+ "protocol": 1,
64
+ "type": "plugin",
65
+ "entry": "./plugin.js",
66
+ "engine": "^1.0.0",
67
+ "runtime": "trusted",
68
+ "features": [
69
+ {
70
+ "package": "@zhin.js/middleware",
71
+ "api": "^1.0.0"
72
+ }
73
+ ],
74
+ "plugins": []
75
+ },
76
+ "scripts": {
77
+ "build": "tsc",
78
+ "clean": "rimraf lib",
79
+ "test": "NODE_OPTIONS=--experimental-strip-types vitest run --root ../../.. plugins/utils/content-moderation/tests"
80
+ }
81
+ }
package/plugin.js ADDED
@@ -0,0 +1,8 @@
1
+ // Generated by build-plugin-runtime-entries.mjs. Do not edit.
2
+ import { definePlugin } from '@zhin.js/plugin-runtime';
3
+ export default definePlugin({
4
+ name: 'content-moderation',
5
+ metadata: {
6
+ displayName: 'Content Moderation',
7
+ },
8
+ });
package/schema.json ADDED
@@ -0,0 +1,240 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "additionalProperties": false,
5
+ "properties": {
6
+ "enabled": {
7
+ "type": "boolean",
8
+ "default": true,
9
+ "description": "总开关"
10
+ },
11
+ "onError": {
12
+ "type": "string",
13
+ "enum": ["open", "closed"],
14
+ "default": "open",
15
+ "description": "审查源失败时的全局默认策略:open=视为通过,closed=视为 critical"
16
+ },
17
+ "maskChar": {
18
+ "type": "string",
19
+ "default": "*",
20
+ "minLength": 1,
21
+ "maxLength": 4,
22
+ "description": "文本打码字符"
23
+ },
24
+ "replyTemplate": {
25
+ "type": "string",
26
+ "default": "消息含不当内容,已拦截。",
27
+ "description": "reply 动作时的提示文案"
28
+ },
29
+ "masters": {
30
+ "type": "array",
31
+ "items": { "type": "string" },
32
+ "default": [],
33
+ "description": "额外视为 master 的 userId(兜底,与 endpoint master 合并)"
34
+ },
35
+ "inbound": {
36
+ "type": "object",
37
+ "additionalProperties": false,
38
+ "properties": {
39
+ "enabled": { "type": "boolean", "default": true },
40
+ "bypassMasters": {
41
+ "type": "boolean",
42
+ "default": true,
43
+ "description": "master 跳过入站审查"
44
+ },
45
+ "whitelist": {
46
+ "type": "object",
47
+ "additionalProperties": false,
48
+ "properties": {
49
+ "userIds": {
50
+ "type": "array",
51
+ "items": { "type": "string" },
52
+ "default": []
53
+ },
54
+ "conversationIds": {
55
+ "type": "array",
56
+ "items": { "type": "string" },
57
+ "default": []
58
+ }
59
+ }
60
+ }
61
+ }
62
+ },
63
+ "outbound": {
64
+ "type": "object",
65
+ "additionalProperties": false,
66
+ "properties": {
67
+ "enabled": { "type": "boolean", "default": true },
68
+ "bypass": {
69
+ "type": "boolean",
70
+ "default": false,
71
+ "description": "为 true 时跳过出站审查"
72
+ }
73
+ }
74
+ },
75
+ "actions": {
76
+ "type": "object",
77
+ "additionalProperties": false,
78
+ "description": "severity → 动作(字符串或数组);缺省用偏严默认表",
79
+ "properties": {
80
+ "pass": {
81
+ "anyOf": [
82
+ {
83
+ "type": "string",
84
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
85
+ },
86
+ {
87
+ "type": "array",
88
+ "minItems": 1,
89
+ "items": {
90
+ "type": "string",
91
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
92
+ }
93
+ }
94
+ ]
95
+ },
96
+ "low": {
97
+ "anyOf": [
98
+ {
99
+ "type": "string",
100
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
101
+ },
102
+ {
103
+ "type": "array",
104
+ "minItems": 1,
105
+ "items": {
106
+ "type": "string",
107
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
108
+ }
109
+ }
110
+ ]
111
+ },
112
+ "medium": {
113
+ "anyOf": [
114
+ {
115
+ "type": "string",
116
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
117
+ },
118
+ {
119
+ "type": "array",
120
+ "minItems": 1,
121
+ "items": {
122
+ "type": "string",
123
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
124
+ }
125
+ }
126
+ ]
127
+ },
128
+ "high": {
129
+ "anyOf": [
130
+ {
131
+ "type": "string",
132
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
133
+ },
134
+ {
135
+ "type": "array",
136
+ "minItems": 1,
137
+ "items": {
138
+ "type": "string",
139
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
140
+ }
141
+ }
142
+ ]
143
+ },
144
+ "critical": {
145
+ "anyOf": [
146
+ {
147
+ "type": "string",
148
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
149
+ },
150
+ {
151
+ "type": "array",
152
+ "minItems": 1,
153
+ "items": {
154
+ "type": "string",
155
+ "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
156
+ }
157
+ }
158
+ ]
159
+ }
160
+ }
161
+ },
162
+ "sources": {
163
+ "type": "array",
164
+ "default": [],
165
+ "items": {
166
+ "type": "object",
167
+ "required": ["id", "type"],
168
+ "additionalProperties": false,
169
+ "properties": {
170
+ "id": { "type": "string", "minLength": 1 },
171
+ "type": { "type": "string", "enum": ["local", "http"] },
172
+ "enabled": { "type": "boolean", "default": true },
173
+ "onError": { "type": "string", "enum": ["open", "closed"] },
174
+ "includeBuiltin": {
175
+ "type": "boolean",
176
+ "default": true,
177
+ "description": "是否合并内置分级违禁词词库"
178
+ },
179
+ "words": {
180
+ "type": "array",
181
+ "default": [],
182
+ "description": "自定义词:字符串(用 defaultSeverity)或 { word, severity }",
183
+ "items": {
184
+ "anyOf": [
185
+ { "type": "string", "minLength": 1 },
186
+ {
187
+ "type": "object",
188
+ "additionalProperties": false,
189
+ "required": ["word"],
190
+ "properties": {
191
+ "word": { "type": "string", "minLength": 1 },
192
+ "text": { "type": "string", "minLength": 1 },
193
+ "severity": {
194
+ "type": "string",
195
+ "enum": ["low", "medium", "high", "critical"]
196
+ }
197
+ }
198
+ }
199
+ ]
200
+ }
201
+ },
202
+ "wordFiles": {
203
+ "type": "array",
204
+ "items": { "type": "string" },
205
+ "default": [],
206
+ "description": "词库文件;行格式 word / severity:word / word|severity"
207
+ },
208
+ "defaultSeverity": {
209
+ "type": "string",
210
+ "enum": ["low", "medium", "high", "critical"],
211
+ "default": "high",
212
+ "description": "未标注分级的自定义词默认 severity"
213
+ },
214
+ "severity": {
215
+ "type": "string",
216
+ "enum": ["low", "medium", "high", "critical"],
217
+ "default": "high",
218
+ "description": "兼容旧字段,等同 defaultSeverity"
219
+ },
220
+ "url": { "type": "string" },
221
+ "headers": {
222
+ "type": "object",
223
+ "additionalProperties": { "type": "string" }
224
+ },
225
+ "timeoutMs": {
226
+ "type": "number",
227
+ "default": 5000,
228
+ "minimum": 500,
229
+ "maximum": 60000
230
+ },
231
+ "forceUpload": {
232
+ "type": "boolean",
233
+ "default": false,
234
+ "description": "强制下载图片后上传,不传 URL"
235
+ }
236
+ }
237
+ }
238
+ }
239
+ }
240
+ }
package/src/bypass.ts ADDED
@@ -0,0 +1,38 @@
1
+ import type { ModerationConfig } from './types.js';
2
+
3
+ export interface BypassInput {
4
+ readonly sender?: string;
5
+ readonly conversationId: string;
6
+ /** Endpoint / plugin-declared masters already resolved by caller. */
7
+ readonly endpointMasters?: readonly string[];
8
+ }
9
+
10
+ export function shouldBypassInbound(
11
+ config: ModerationConfig,
12
+ input: BypassInput,
13
+ ): boolean {
14
+ if (!config.enabled || !config.inbound.enabled) return true;
15
+
16
+ const sender = input.sender?.trim();
17
+ if (sender) {
18
+ if (config.inbound.whitelist.userIds.includes(sender)) return true;
19
+ if (config.inbound.bypassMasters) {
20
+ const masters = new Set([
21
+ ...config.masters,
22
+ ...(input.endpointMasters ?? []),
23
+ ].map(String));
24
+ if (masters.has(sender)) return true;
25
+ }
26
+ }
27
+
28
+ if (config.inbound.whitelist.conversationIds.includes(input.conversationId)) {
29
+ return true;
30
+ }
31
+
32
+ return false;
33
+ }
34
+
35
+ export function shouldBypassOutbound(config: ModerationConfig): boolean {
36
+ if (!config.enabled || !config.outbound.enabled) return true;
37
+ return config.outbound.bypass === true;
38
+ }