@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.
@@ -1,6 +1,8 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import type { AiCompletion, AiCompletionResult, PluginStateStore } from 'zhin.js';
3
3
  import type { EndpointManagement } from 'zhin.js/adapter';
4
+ import type { Locale } from 'zhin.js/i18n';
5
+ import { moderationMessages } from './messages.js';
4
6
 
5
7
  const QUIET_MS = 30 * 60_000;
6
8
  const NEWCOMER_MS = 10 * 60_000;
@@ -51,6 +53,8 @@ export function parseGroupReview(text: string, original: string,
51
53
  }
52
54
 
53
55
  export interface GroupModerationInput {
56
+ /** Captured when the original inbound message is admitted, before asynchronous review. */
57
+ readonly locale?: Locale;
54
58
  readonly scope: string;
55
59
  readonly group: string;
56
60
  readonly sender: string;
@@ -70,13 +74,39 @@ export interface GroupModerationInput {
70
74
  readonly recall: () => Promise<void>;
71
75
  readonly audit: (event: Readonly<Record<string, unknown>>) => void;
72
76
  readonly onReview?: () => void;
77
+ /**
78
+ * 群规执行器(ADR 0039 §5,Agent host 提供)。判定后做什么交给它:`handled` 时本插件不再提醒或处罚;
79
+ * `no_rule`(该群还没确认规则)或未装 Agent 时,本插件只提醒 / 警告,不禁言、不踢人。
80
+ */
81
+ readonly rules?: (violation: GroupRuleHit) => Promise<'handled' | 'no_rule'>;
82
+ }
83
+
84
+ /** 交给群规执行器的一次命中(目标、群号由中间件绑定,模型够不着)。 */
85
+ export interface GroupRuleHit {
86
+ readonly category: Category;
87
+ readonly evidence: readonly string[];
88
+ /** 入群 10 分钟内发广告:执行器出踢人审批卡(每次人批),不再无人审批直接踢。 */
89
+ readonly newcomer: boolean;
90
+ readonly recall?: () => Promise<void>;
73
91
  }
74
92
 
75
93
  /** Generation-owned history only; enforcement state uses the existing atomic Database Host. */
76
94
  export class GroupModerator {
77
95
  readonly #history = new Map<string, readonly (GroupContextEntry & { at: number })[]>();
96
+ /** Groups whose default rules were already requested this generation (bounded by the configured group list). */
97
+ readonly #rulesEnsured = new Set<string>();
78
98
  #sequence = 0;
79
99
 
100
+ /**
101
+ * ADR 0039 §5 migration: once per generation per configured group, ask the Agent host for the default
102
+ * pending rules (advertising / abuse). Failures are retried on a later message.
103
+ */
104
+ ensureRules(scope: string, ensure: () => Promise<void>): void {
105
+ if (this.#rulesEnsured.has(scope)) return;
106
+ this.#rulesEnsured.add(scope);
107
+ void ensure().catch(() => this.#rulesEnsured.delete(scope));
108
+ }
109
+
80
110
  /** A full queue must still respect a review started by a previous generation. */
81
111
  async ownsRecall(input: GroupModerationInput): Promise<boolean> {
82
112
  const window = await input.state.get<SeenWindow>(`group-moderation:seen:${digest(input.sender)}`);
@@ -103,7 +133,7 @@ export class GroupModerator {
103
133
 
104
134
  async review(input: GroupModerationInput, classify?: (signal: AbortSignal) => Promise<AiCompletionResult>): Promise<void> {
105
135
  const { state, management } = input;
106
- if (!management.inspectGroupMember || !management.moderateGroupMember
136
+ if (!management.inspectGroupMember
107
137
  || !input.messageId || !Number.isSafeInteger(input.sentAt) || input.sentAt <= 0
108
138
  || input.sentAt > Date.now() || Date.now() - input.sentAt >= QUIET_MS
109
139
  || !input.text.trim() || input.text.length > 12_000) return;
@@ -185,29 +215,34 @@ export class GroupModerator {
185
215
  && Date.now() - member.joinedAt <= NEWCOMER_MS;
186
216
  // Messages already sent before the last delivered notice cannot advance escalation.
187
217
  if (input.sentAt <= stage.notifiedAt) return;
188
- const count = Math.min(stage.count + 1, 3);
189
218
  input.audit({ event: 'group_review', category: review.category, messageId: input.messageId,
190
219
  evidence: review.evidence, recall: review.recall, recallReason: review.recallReason });
191
- if (!newcomer && stage.count >= 3) {
220
+ signal.throwIfAborted();
221
+ // Persisted lease predates every side effect. Unknown outcomes retain it, preventing retries.
222
+ effectStarted = true;
223
+ // ADR 0039 §5: punishment belongs to the approved group rule. The enforcer owns its own ladder,
224
+ // mutes only under an approved rule, and turns a newcomer ad into a per-kick approval card.
225
+ if (input.rules) {
226
+ const outcome = await input.rules({ category: review.category, evidence: review.evidence, newcomer,
227
+ ...(review.recall ? { recall: input.recall } : {}) });
228
+ if (outcome === 'handled') {
229
+ completed = true;
230
+ return;
231
+ }
232
+ signal.throwIfAborted();
233
+ }
234
+ // No confirmed rule (or no Agent host): remind, then warn. Never mute or kick here.
235
+ if (stage.count >= 2) {
192
236
  await state.update<MemberState>(memberKey, (value) => value?.lease === token
193
237
  ? { ...value, [review.category]: { ...stage, lastViolationAt: input.sentAt } } : value,
194
238
  { ttlMs: QUIET_MS });
239
+ completed = true;
195
240
  return;
196
241
  }
197
- signal.throwIfAborted();
198
- // Persisted lease predates every side effect. Unknown outcomes retain it, preventing retries.
199
- effectStarted = true;
200
- if (newcomer) {
201
- await management.moderateGroupMember(input.group, input.sender, {
202
- type: 'kick', joinedAt: member.joinedAt!, maxMembershipAgeMs: NEWCOMER_MS,
203
- }, signal);
204
- } else if (count === 3) {
205
- await management.moderateGroupMember(input.group, input.sender, { type: 'mute', durationSeconds: 600 }, signal);
206
- }
207
- signal.throwIfAborted();
208
- const rule = review.category === 'advertising' ? '请勿发布未经请求的广告或引流信息' : '请勿针对他人进行人身攻击';
209
- const action = newcomer ? '入群 10 分钟内发布明确广告,已移出群聊' : count === 3 ? '已禁言 10 分钟' : count === 2 ? '警告' : '提醒';
210
- await input.reply(`${action}。${rule}。`, input.sender);
242
+ const count = stage.count + 1;
243
+ const rule = moderationMessages(input.locale, review.category);
244
+ const action = moderationMessages(input.locale, count === 2 ? 'warning' : 'reminder');
245
+ await input.reply(moderationMessages(input.locale, 'notice', { action, rule }), input.sender);
211
246
  const notifiedAt = Date.now();
212
247
  // Commit the delivered stage and release together, before optional recall.
213
248
  await state.update<MemberState>(memberKey, (value) => value?.lease === token
@@ -216,7 +251,7 @@ export class GroupModerator {
216
251
  completed = true;
217
252
  claimed = false;
218
253
  // Optional recall is independent of escalation and cannot undo a successfully delivered notice.
219
- if (!newcomer && count < 3 && review.recall && !signal.aborted) {
254
+ if (review.recall && !signal.aborted) {
220
255
  try { await input.recall(); }
221
256
  catch { input.audit({ event: 'group_recall_failed', messageId: input.messageId }); }
222
257
  }
@@ -0,0 +1,10 @@
1
+ import { createMessages } from 'zhin.js/i18n';
2
+
3
+ export const moderationMessages = createMessages({
4
+ blocked: { 'zh-CN': '消息含不当内容,已拦截。', en: 'This message contains inappropriate content and was blocked.' },
5
+ advertising: { 'zh-CN': '请勿发布未经请求的广告或引流信息', en: 'Do not post unsolicited advertising or promotional invitations' },
6
+ abuse: { 'zh-CN': '请勿针对他人进行人身攻击', en: 'Do not direct personal attacks at others' },
7
+ warning: { 'zh-CN': '警告', en: 'Warning' },
8
+ reminder: { 'zh-CN': '提醒', en: 'Reminder' },
9
+ notice: { 'zh-CN': '{action}。{rule}。', en: '{action}. {rule}.' },
10
+ });
package/src/types.ts CHANGED
@@ -97,7 +97,8 @@ export interface ModerationConfig {
97
97
  readonly enabled: boolean;
98
98
  readonly onError: OnErrorPolicy;
99
99
  readonly maskChar: string;
100
- readonly replyTemplate: string;
100
+ /** Omitted templates use the inbound operation's language. Explicit text is never translated. */
101
+ readonly replyTemplate?: string;
101
102
  readonly masters: readonly string[];
102
103
  readonly inbound: {
103
104
  readonly enabled: boolean;