@zhin.js/plugin-content-moderation 1.1.15 → 1.1.17

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.
@@ -3,7 +3,9 @@ import type { Message } from '@zhin.js/core/runtime';
3
3
  import { aiCompletionToken, createDeliveryTarget, databaseHostToken, getLogger, hasResource,
4
4
  outboundHostToken, usageSubjectFromMessage } from 'zhin.js';
5
5
  import { adapterFeatureId, type AdapterIndex } from 'zhin.js/adapter';
6
+ import { groupRuleViolationToken, type GroupRuleViolationSink } from '@zhin.js/core/runtime';
6
7
  import { generationAdmissionSource } from 'zhin.js/plugin-runtime';
8
+ import { resolveLocale } from 'zhin.js/i18n';
7
9
  import {
8
10
  buildScanContext,
9
11
  extractFromTextAndSegments,
@@ -30,6 +32,7 @@ export default defineMiddleware<Message, ModerationConfig>({
30
32
  }
31
33
 
32
34
  const message = context.input;
35
+ const locale = resolveLocale(message.locale);
33
36
  const sender = message.sender?.id;
34
37
  const conversationId = message.conversation.id;
35
38
  const endpointMasters = resolveEndpointMasters(message, config);
@@ -70,6 +73,7 @@ export default defineMiddleware<Message, ModerationConfig>({
70
73
 
71
74
  const result = await engine.apply({
72
75
  direction: 'inbound',
76
+ locale,
73
77
  recallManaged: review?.done,
74
78
  extracted,
75
79
  scanInput,
@@ -115,6 +119,7 @@ async function enqueueGroupModeration(
115
119
  config: ModerationConfig, bypass: boolean,
116
120
  ): Promise<{ done: Promise<boolean> } | undefined> {
117
121
  const message = context.input;
122
+ const locale = resolveLocale(message.locale);
118
123
  const conversation = structuredClone(message.conversation);
119
124
  const target = config.groupModeration.find((item) => conversation.kind === 'group'
120
125
  && item.adapter === conversation.endpoint.adapter
@@ -132,6 +137,19 @@ async function enqueueGroupModeration(
132
137
  const destination = createDeliveryTarget({ conversation });
133
138
  const scope = JSON.stringify(destination);
134
139
  const remembered = engine.groupModerator.remember(scope, message.sender?.id ?? '', text);
140
+ // ADR 0039 §5: punishment is the group rule's job. A configured group gets default pending rules once,
141
+ // and stays reminder / warning only until an owner or admin confirms them.
142
+ const rules: GroupRuleViolationSink | undefined = hasResource(context, groupRuleViolationToken)
143
+ ? context.use(groupRuleViolationToken) : undefined;
144
+ if (rules) {
145
+ engine.groupModerator.ensureRules(scope, async () => {
146
+ for (const trigger of ['advertising', 'abuse'] as const) {
147
+ const outcome = await rules.ensureDefaultRule({ conversation, trigger });
148
+ if (outcome === 'unavailable') throw new Error('Group rule storage unavailable');
149
+ if (outcome === 'created') logger.info({ event: 'group_rule_migrated', trigger, endpoint: conversation.endpoint.id, group: conversation.id });
150
+ }
151
+ });
152
+ }
135
153
  if (!bypass && message.sender?.id && message.origin !== 'self'
136
154
  && hasResource(context, aiCompletionToken) && hasResource(context, databaseHostToken)
137
155
  && hasResource(context, outboundHostToken)) {
@@ -151,7 +169,7 @@ async function enqueueGroupModeration(
151
169
  };
152
170
  try {
153
171
  const queued = await engine.groupReviewQueue.enqueue({
154
- scope, group: conversation.id, sender: message.sender.id,
172
+ scope, group: conversation.id, sender: message.sender.id, locale,
155
173
  messageId, sentAt: Number(message.metadata?.platformSentAt), text, ...remembered,
156
174
  signal,
157
175
  state: context.use(databaseHostToken).state.forConversation(destination),
@@ -159,17 +177,25 @@ async function enqueueGroupModeration(
159
177
  inspectGroupMember: endpoint.management?.inspectGroupMember ? async (group, member, operationSignal) => {
160
178
  assertTarget(); return endpoint.management!.inspectGroupMember!(group, member, operationSignal);
161
179
  } : undefined,
162
- moderateGroupMember: endpoint.management?.moderateGroupMember ? async (group, member, action, operationSignal) => {
163
- assertTarget(); return endpoint.management!.moderateGroupMember!(group, member, action, operationSignal);
164
- } : undefined,
165
180
  },
181
+ ...(rules ? { rules: async (hit) => {
182
+ assertTarget();
183
+ return rules.report({
184
+ conversation, memberId: message.sender!.id, trigger: hit.category, newcomer: hit.newcomer,
185
+ sentAt: Number(message.metadata?.platformSentAt), evidence: hit.evidence,
186
+ ...(message.sender?.name ? { memberName: message.sender.name } : {}),
187
+ ...(message.sender?.roles ? { memberRoles: message.sender.roles } : {}),
188
+ ...(messageId ? { messageId } : {}),
189
+ ...(hit.recall ? { recall: hit.recall } : {}),
190
+ });
191
+ } } : {}),
166
192
  onBehalfOf: usageSubjectFromMessage(message),
167
193
  reply: async (content, mention) => {
168
194
  assertTarget();
169
195
  const receipt = await host.sendTarget!(destination, mention ? [
170
196
  { type: 'mention', data: { target: mention } },
171
197
  { type: 'text', data: { text: ` ${content}` } },
172
- ] : content);
198
+ ] : content, { locale });
173
199
  if (!receipt) throw new Error('Moderation notice was not confirmed');
174
200
  },
175
201
  recall: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/plugin-content-moderation",
3
- "version": "1.1.15",
3
+ "version": "1.1.17",
4
4
  "description": "Content moderation plugin for Zhin.js — inbound/outbound multi-source scanning (Plugin Runtime)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -37,13 +37,13 @@
37
37
  },
38
38
  "license": "MIT",
39
39
  "dependencies": {
40
- "@zhin.js/core": "1.1.50",
40
+ "@zhin.js/core": "1.1.52",
41
41
  "@zhin.js/logger": "1.1.2"
42
42
  },
43
43
  "devDependencies": {
44
44
  "typescript": "^6.0.3",
45
45
  "vitest": "^4.1.10",
46
- "zhin.js": "1.1.15"
46
+ "zhin.js": "1.1.17"
47
47
  },
48
48
  "repository": {
49
49
  "type": "git",
@@ -72,8 +72,8 @@
72
72
  "plugins": []
73
73
  },
74
74
  "peerDependencies": {
75
- "@zhin.js/middleware": "^1.1.9",
76
- "zhin.js": "^1.1.15"
75
+ "@zhin.js/middleware": "^1.1.10",
76
+ "zhin.js": "^1.1.17"
77
77
  },
78
78
  "peerDependenciesMeta": {
79
79
  "@zhin.js/middleware": {
package/schema.json CHANGED
@@ -6,7 +6,7 @@
6
6
  "groupModeration": {
7
7
  "type": "array",
8
8
  "default": [],
9
- "description": "自动广告/辱骂审核的精确范围;未配置时关闭。需要 AI、原子持久状态和适配器群管支持。",
9
+ "description": "自动广告/辱骂审核的精确范围;未配置时关闭。需要 AI、原子持久状态和适配器群管支持。禁言 / 踢人交给 Agent 群规(ADR 0039),名单内的群会生成待确认的默认规则,确认前只提醒 / 警告。",
10
10
  "items": {
11
11
  "type": "object",
12
12
  "additionalProperties": false,
@@ -34,46 +34,88 @@
34
34
  "minLength": 1
35
35
  }
36
36
  }
37
+ },
38
+ "x-i18n": {
39
+ "en": {
40
+ "description": "Exact scope for automatic advertising/abuse moderation. Disabled when unconfigured. Requires AI, atomic persistent state and adapter group-management support."
41
+ }
37
42
  }
38
43
  },
39
44
  "enabled": {
40
45
  "type": "boolean",
41
46
  "default": true,
42
- "description": "总开关"
47
+ "description": "总开关",
48
+ "x-i18n": {
49
+ "en": {
50
+ "description": "Enable moderation."
51
+ }
52
+ }
43
53
  },
44
54
  "onError": {
45
55
  "type": "string",
46
- "enum": ["open", "closed"],
56
+ "enum": [
57
+ "open",
58
+ "closed"
59
+ ],
47
60
  "default": "open",
48
- "description": "审查源失败时的全局默认策略:open=视为通过,closed=视为 critical"
61
+ "description": "审查源失败时的全局默认策略:open=视为通过,closed=视为 critical",
62
+ "x-i18n": {
63
+ "en": {
64
+ "description": "Default policy when a review source fails: open treats it as passed; closed treats it as critical."
65
+ }
66
+ }
49
67
  },
50
68
  "maskChar": {
51
69
  "type": "string",
52
70
  "default": "*",
53
71
  "minLength": 1,
54
72
  "maxLength": 4,
55
- "description": "文本打码字符"
73
+ "description": "文本打码字符",
74
+ "x-i18n": {
75
+ "en": {
76
+ "description": "Text masking character."
77
+ }
78
+ }
56
79
  },
57
80
  "replyTemplate": {
58
81
  "type": "string",
59
- "default": "消息含不当内容,已拦截。",
60
- "description": "reply 动作时的提示文案"
82
+ "description": "reply 动作时的提示文案;省略时按本次消息语言选择默认文案,显式文本保持原样",
83
+ "x-i18n": {
84
+ "en": {
85
+ "description": "Reply text for the reply action. When omitted, use the message language; explicit text is preserved."
86
+ }
87
+ }
61
88
  },
62
89
  "masters": {
63
90
  "type": "array",
64
- "items": { "type": "string" },
91
+ "items": {
92
+ "type": "string"
93
+ },
65
94
  "default": [],
66
- "description": "额外视为 master 的 userId(兜底,与 endpoint master 合并)"
95
+ "description": "额外视为 master 的 userId(兜底,与 endpoint master 合并)",
96
+ "x-i18n": {
97
+ "en": {
98
+ "description": "Additional user IDs treated as masters, merged with endpoint masters as a fallback."
99
+ }
100
+ }
67
101
  },
68
102
  "inbound": {
69
103
  "type": "object",
70
104
  "additionalProperties": false,
71
105
  "properties": {
72
- "enabled": { "type": "boolean", "default": true },
106
+ "enabled": {
107
+ "type": "boolean",
108
+ "default": true
109
+ },
73
110
  "bypassMasters": {
74
111
  "type": "boolean",
75
112
  "default": true,
76
- "description": "master 跳过入站审查"
113
+ "description": "master 跳过入站审查",
114
+ "x-i18n": {
115
+ "en": {
116
+ "description": "Skip inbound review for masters."
117
+ }
118
+ }
77
119
  },
78
120
  "whitelist": {
79
121
  "type": "object",
@@ -81,12 +123,16 @@
81
123
  "properties": {
82
124
  "userIds": {
83
125
  "type": "array",
84
- "items": { "type": "string" },
126
+ "items": {
127
+ "type": "string"
128
+ },
85
129
  "default": []
86
130
  },
87
131
  "conversationIds": {
88
132
  "type": "array",
89
- "items": { "type": "string" },
133
+ "items": {
134
+ "type": "string"
135
+ },
90
136
  "default": []
91
137
  }
92
138
  }
@@ -97,11 +143,19 @@
97
143
  "type": "object",
98
144
  "additionalProperties": false,
99
145
  "properties": {
100
- "enabled": { "type": "boolean", "default": true },
146
+ "enabled": {
147
+ "type": "boolean",
148
+ "default": true
149
+ },
101
150
  "bypass": {
102
151
  "type": "boolean",
103
152
  "default": false,
104
- "description": "为 true 时跳过出站审查"
153
+ "description": "为 true 时跳过出站审查",
154
+ "x-i18n": {
155
+ "en": {
156
+ "description": "Skip outbound review when true."
157
+ }
158
+ }
105
159
  }
106
160
  }
107
161
  },
@@ -114,14 +168,28 @@
114
168
  "anyOf": [
115
169
  {
116
170
  "type": "string",
117
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
171
+ "enum": [
172
+ "allow",
173
+ "log",
174
+ "reply",
175
+ "redact",
176
+ "drop",
177
+ "recall"
178
+ ]
118
179
  },
119
180
  {
120
181
  "type": "array",
121
182
  "minItems": 1,
122
183
  "items": {
123
184
  "type": "string",
124
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
185
+ "enum": [
186
+ "allow",
187
+ "log",
188
+ "reply",
189
+ "redact",
190
+ "drop",
191
+ "recall"
192
+ ]
125
193
  }
126
194
  }
127
195
  ]
@@ -130,14 +198,28 @@
130
198
  "anyOf": [
131
199
  {
132
200
  "type": "string",
133
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
201
+ "enum": [
202
+ "allow",
203
+ "log",
204
+ "reply",
205
+ "redact",
206
+ "drop",
207
+ "recall"
208
+ ]
134
209
  },
135
210
  {
136
211
  "type": "array",
137
212
  "minItems": 1,
138
213
  "items": {
139
214
  "type": "string",
140
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
215
+ "enum": [
216
+ "allow",
217
+ "log",
218
+ "reply",
219
+ "redact",
220
+ "drop",
221
+ "recall"
222
+ ]
141
223
  }
142
224
  }
143
225
  ]
@@ -146,14 +228,28 @@
146
228
  "anyOf": [
147
229
  {
148
230
  "type": "string",
149
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
231
+ "enum": [
232
+ "allow",
233
+ "log",
234
+ "reply",
235
+ "redact",
236
+ "drop",
237
+ "recall"
238
+ ]
150
239
  },
151
240
  {
152
241
  "type": "array",
153
242
  "minItems": 1,
154
243
  "items": {
155
244
  "type": "string",
156
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
245
+ "enum": [
246
+ "allow",
247
+ "log",
248
+ "reply",
249
+ "redact",
250
+ "drop",
251
+ "recall"
252
+ ]
157
253
  }
158
254
  }
159
255
  ]
@@ -162,14 +258,28 @@
162
258
  "anyOf": [
163
259
  {
164
260
  "type": "string",
165
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
261
+ "enum": [
262
+ "allow",
263
+ "log",
264
+ "reply",
265
+ "redact",
266
+ "drop",
267
+ "recall"
268
+ ]
166
269
  },
167
270
  {
168
271
  "type": "array",
169
272
  "minItems": 1,
170
273
  "items": {
171
274
  "type": "string",
172
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
275
+ "enum": [
276
+ "allow",
277
+ "log",
278
+ "reply",
279
+ "redact",
280
+ "drop",
281
+ "recall"
282
+ ]
173
283
  }
174
284
  }
175
285
  ]
@@ -178,18 +288,37 @@
178
288
  "anyOf": [
179
289
  {
180
290
  "type": "string",
181
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
291
+ "enum": [
292
+ "allow",
293
+ "log",
294
+ "reply",
295
+ "redact",
296
+ "drop",
297
+ "recall"
298
+ ]
182
299
  },
183
300
  {
184
301
  "type": "array",
185
302
  "minItems": 1,
186
303
  "items": {
187
304
  "type": "string",
188
- "enum": ["allow", "log", "reply", "redact", "drop", "recall"]
305
+ "enum": [
306
+ "allow",
307
+ "log",
308
+ "reply",
309
+ "redact",
310
+ "drop",
311
+ "recall"
312
+ ]
189
313
  }
190
314
  }
191
315
  ]
192
316
  }
317
+ },
318
+ "x-i18n": {
319
+ "en": {
320
+ "description": "Severity-to-action mapping (string or array); uses the stricter default mapping when omitted."
321
+ }
193
322
  }
194
323
  },
195
324
  "sources": {
@@ -197,17 +326,43 @@
197
326
  "default": [],
198
327
  "items": {
199
328
  "type": "object",
200
- "required": ["id", "type"],
329
+ "required": [
330
+ "id",
331
+ "type"
332
+ ],
201
333
  "additionalProperties": false,
202
334
  "properties": {
203
- "id": { "type": "string", "minLength": 1 },
204
- "type": { "type": "string", "enum": ["local", "http"] },
205
- "enabled": { "type": "boolean", "default": true },
206
- "onError": { "type": "string", "enum": ["open", "closed"] },
335
+ "id": {
336
+ "type": "string",
337
+ "minLength": 1
338
+ },
339
+ "type": {
340
+ "type": "string",
341
+ "enum": [
342
+ "local",
343
+ "http"
344
+ ]
345
+ },
346
+ "enabled": {
347
+ "type": "boolean",
348
+ "default": true
349
+ },
350
+ "onError": {
351
+ "type": "string",
352
+ "enum": [
353
+ "open",
354
+ "closed"
355
+ ]
356
+ },
207
357
  "includeBuiltin": {
208
358
  "type": "boolean",
209
359
  "default": true,
210
- "description": "是否合并内置分级违禁词词库"
360
+ "description": "是否合并内置分级违禁词词库",
361
+ "x-i18n": {
362
+ "en": {
363
+ "description": "Include the built-in graded blocked-word dictionary."
364
+ }
365
+ }
211
366
  },
212
367
  "words": {
213
368
  "type": "array",
@@ -215,45 +370,97 @@
215
370
  "description": "自定义词:字符串(用 defaultSeverity)或 { word, severity }",
216
371
  "items": {
217
372
  "anyOf": [
218
- { "type": "string", "minLength": 1 },
373
+ {
374
+ "type": "string",
375
+ "minLength": 1
376
+ },
219
377
  {
220
378
  "type": "object",
221
379
  "additionalProperties": false,
222
- "required": ["word"],
380
+ "required": [
381
+ "word"
382
+ ],
223
383
  "properties": {
224
- "word": { "type": "string", "minLength": 1 },
225
- "text": { "type": "string", "minLength": 1 },
384
+ "word": {
385
+ "type": "string",
386
+ "minLength": 1
387
+ },
388
+ "text": {
389
+ "type": "string",
390
+ "minLength": 1
391
+ },
226
392
  "severity": {
227
393
  "type": "string",
228
- "enum": ["low", "medium", "high", "critical"]
394
+ "enum": [
395
+ "low",
396
+ "medium",
397
+ "high",
398
+ "critical"
399
+ ]
229
400
  }
230
401
  }
231
402
  }
232
403
  ]
404
+ },
405
+ "x-i18n": {
406
+ "en": {
407
+ "description": "Custom words: strings using defaultSeverity, or { word, severity } objects."
408
+ }
233
409
  }
234
410
  },
235
411
  "wordFiles": {
236
412
  "type": "array",
237
- "items": { "type": "string" },
413
+ "items": {
414
+ "type": "string"
415
+ },
238
416
  "default": [],
239
- "description": "词库文件;行格式 word / severity:word / word|severity"
417
+ "description": "词库文件;行格式 word / severity:word / word|severity",
418
+ "x-i18n": {
419
+ "en": {
420
+ "description": "Dictionary file; line formats: word, severity:word, or word|severity."
421
+ }
422
+ }
240
423
  },
241
424
  "defaultSeverity": {
242
425
  "type": "string",
243
- "enum": ["low", "medium", "high", "critical"],
426
+ "enum": [
427
+ "low",
428
+ "medium",
429
+ "high",
430
+ "critical"
431
+ ],
244
432
  "default": "high",
245
- "description": "未标注分级的自定义词默认 severity"
433
+ "description": "未标注分级的自定义词默认 severity",
434
+ "x-i18n": {
435
+ "en": {
436
+ "description": "Default severity for custom words without an explicit severity."
437
+ }
438
+ }
246
439
  },
247
440
  "severity": {
248
441
  "type": "string",
249
- "enum": ["low", "medium", "high", "critical"],
442
+ "enum": [
443
+ "low",
444
+ "medium",
445
+ "high",
446
+ "critical"
447
+ ],
250
448
  "default": "high",
251
- "description": "兼容旧字段,等同 defaultSeverity"
449
+ "description": "兼容旧字段,等同 defaultSeverity",
450
+ "x-i18n": {
451
+ "en": {
452
+ "description": "Legacy alias for defaultSeverity."
453
+ }
454
+ }
455
+ },
456
+ "url": {
457
+ "type": "string"
252
458
  },
253
- "url": { "type": "string" },
254
459
  "headers": {
255
460
  "type": "object",
256
- "additionalProperties": { "type": "string" }
461
+ "additionalProperties": {
462
+ "type": "string"
463
+ }
257
464
  },
258
465
  "timeoutMs": {
259
466
  "type": "number",
@@ -264,7 +471,12 @@
264
471
  "forceUpload": {
265
472
  "type": "boolean",
266
473
  "default": false,
267
- "description": "强制下载图片后上传,不传 URL"
474
+ "description": "强制下载图片后上传,不传 URL",
475
+ "x-i18n": {
476
+ "en": {
477
+ "description": "Download images before uploading them instead of passing the URL."
478
+ }
479
+ }
268
480
  }
269
481
  }
270
482
  }
package/src/config.ts CHANGED
@@ -22,7 +22,6 @@ export const DEFAULT_MODERATION_CONFIG: ModerationConfig = Object.freeze({
22
22
  enabled: true,
23
23
  onError: 'open',
24
24
  maskChar: '*',
25
- replyTemplate: '消息含不当内容,已拦截。',
26
25
  masters: Object.freeze([] as string[]),
27
26
  inbound: Object.freeze({
28
27
  enabled: true,
@@ -51,7 +50,7 @@ export function resolveModerationConfig(raw: unknown): ModerationConfig {
51
50
  : DEFAULT_MODERATION_CONFIG.maskChar;
52
51
  const replyTemplate = typeof input.replyTemplate === 'string' && input.replyTemplate.length > 0
53
52
  ? input.replyTemplate
54
- : DEFAULT_MODERATION_CONFIG.replyTemplate;
53
+ : undefined;
55
54
 
56
55
  return Object.freeze({
57
56
  groupModeration: Object.freeze((Array.isArray(input.groupModeration) ? input.groupModeration : [])
@@ -66,7 +65,7 @@ export function resolveModerationConfig(raw: unknown): ModerationConfig {
66
65
  enabled: input.enabled !== false,
67
66
  onError,
68
67
  maskChar,
69
- replyTemplate,
68
+ ...(replyTemplate === undefined ? {} : { replyTemplate }),
70
69
  masters: freezeStrings(input.masters),
71
70
  inbound: Object.freeze({
72
71
  enabled: inboundRaw.enabled !== false,
package/src/engine.ts CHANGED
@@ -6,6 +6,8 @@ import { createProviders } from './providers/registry.js';
6
6
  import { GroupModerator } from './group-moderation.js';
7
7
  import { GroupReviewQueue } from './group-review-queue.js';
8
8
  import { generationAdmissionSource } from 'zhin.js/plugin-runtime';
9
+ import type { Locale } from 'zhin.js/i18n';
10
+ import { moderationMessages } from './messages.js';
9
11
  import type { ModerationProvider } from './providers/types.js';
10
12
  import {
11
13
  maxSeverity,
@@ -100,6 +102,7 @@ export class ModerationEngine {
100
102
 
101
103
  async apply(options: {
102
104
  readonly direction: Direction;
105
+ readonly locale?: Locale;
103
106
  readonly extracted: ExtractedContent;
104
107
  readonly scanInput: ScanInput;
105
108
  readonly hooks: ApplyHooks;
@@ -147,7 +150,7 @@ export class ModerationEngine {
147
150
 
148
151
  if (actionSet.has('reply') && options.direction === 'inbound') {
149
152
  try {
150
- await options.hooks.reply?.(this.#config.replyTemplate);
153
+ await options.hooks.reply?.(this.#config.replyTemplate ?? moderationMessages(options.locale, 'blocked'));
151
154
  } catch (error) {
152
155
  this.#logger.warn(formatCompact({
153
156
  op: 'moderation_reply_failed',