@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.
- package/README.md +8 -5
- package/lib/config.d.ts.map +1 -1
- package/lib/config.js +2 -3
- package/lib/config.js.map +1 -1
- package/lib/engine.d.ts +2 -0
- package/lib/engine.d.ts.map +1 -1
- package/lib/engine.js +2 -1
- package/lib/engine.js.map +1 -1
- package/lib/group-moderation.d.ts +21 -0
- package/lib/group-moderation.d.ts.map +1 -1
- package/lib/group-moderation.js +35 -18
- package/lib/group-moderation.js.map +1 -1
- package/lib/messages.d.ts +2 -0
- package/lib/messages.d.ts.map +1 -0
- package/lib/messages.js +10 -0
- package/lib/messages.js.map +1 -0
- package/lib/types.d.ts +2 -1
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/middlewares/inbound/index.js +33 -6
- package/middlewares/inbound/index.ts +31 -5
- package/package.json +5 -5
- package/schema.json +257 -45
- package/src/config.ts +2 -3
- package/src/engine.ts +4 -1
- package/src/group-moderation.ts +53 -18
- package/src/messages.ts +10 -0
- package/src/types.ts +2 -1
package/src/group-moderation.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 (
|
|
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
|
}
|
package/src/messages.ts
ADDED
|
@@ -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
|
-
|
|
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;
|