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

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.
@@ -0,0 +1,135 @@
1
+ import { createGenerationAdmissionGate, generationAdmissionSource } from 'zhin.js/plugin-runtime';
2
+ import type { GroupModerationInput, GroupModerator } from './group-moderation.js';
3
+ import { deferred, REQUEST_BATCHES, reviewGroupBatch, takeGroupBatch } from './group-review-batch.js';
4
+
5
+ const COLLECT_MS = 300;
6
+ const MAX_PENDING = 128;
7
+ const MAX_PENDING_BYTES = 256 * 1024;
8
+ type Entry = { input: GroupModerationInput; sender: string; bytes: number; order: number;
9
+ done: ReturnType<typeof deferred<boolean>>; ownsRecall: boolean; detach(): void };
10
+ type Scene = { pending: Entry[]; bytes: number; active: Map<string, Entry> };
11
+
12
+ /** One generation-owned worker; per-scene bounded buffers, no persisted/replayed punishment jobs. */
13
+ export class GroupReviewQueue {
14
+ readonly #moderator: GroupModerator;
15
+ readonly #gate = createGenerationAdmissionGate();
16
+ readonly #abort = new AbortController();
17
+ readonly #scenes = new Map<string, Scene>();
18
+ #timer?: ReturnType<typeof setTimeout>;
19
+ #running = false;
20
+ #order = 0;
21
+ get [generationAdmissionSource]() { return [this.#gate]; }
22
+
23
+ constructor(moderator: GroupModerator) {
24
+ this.#moderator = moderator;
25
+ this.#gate.onActivate(() => this.#gate.onDeactivate(() => this.stop()));
26
+ }
27
+
28
+ stop(): void {
29
+ this.#abort.abort(new Error('Group review generation retired'));
30
+ clearTimeout(this.#timer); this.#timer = undefined;
31
+ for (const scene of this.#scenes.values()) {
32
+ for (const entry of scene.pending) this.#finish(entry);
33
+ scene.pending = []; scene.bytes = 0;
34
+ }
35
+ this.#scenes.clear();
36
+ }
37
+
38
+ /** Resolves on admission, not classification. Full buffers explicitly audit skipped reviews. */
39
+ async enqueue(input: GroupModerationInput): Promise<{ done: Promise<boolean> }> {
40
+ this.#assertActive();
41
+ const signal = AbortSignal.any([this.#abort.signal, input.signal]);
42
+ signal.throwIfAborted();
43
+ const bytes = Buffer.byteLength(input.text);
44
+ // Same eligibility budget as the existing single-message reviewer.
45
+ if (!input.text.trim() || input.text.length > 12_000) return { done: Promise.resolve(false) };
46
+ let scene = this.#scenes.get(input.scope);
47
+ if (!scene) { scene = { pending: [], bytes: 0, active: new Map() }; this.#scenes.set(input.scope, scene); }
48
+ const key = JSON.stringify([input.sender, input.messageId]);
49
+ // A duplicate cannot take the full-buffer path and override an original's pending recall decision.
50
+ if (input.messageId && scene.active.has(key)) return { done: Promise.resolve(true) };
51
+ const order = ++this.#order;
52
+ if (scene.pending.length >= MAX_PENDING || scene.bytes + bytes > MAX_PENDING_BYTES) {
53
+ input.audit({ event: 'group_review_skipped', messageId: input.messageId, reason: 'queue_full',
54
+ pending: scene.pending.length, pendingBytes: scene.bytes });
55
+ return { done: this.#moderator.ownsRecall(input).catch(error => {
56
+ input.audit({ event: 'group_recall_ownership_unavailable', messageId: input.messageId,
57
+ error: error instanceof Error ? error.message : String(error) });
58
+ return true; // Unknown ownership is not permission to delete an original message.
59
+ }) };
60
+ }
61
+ const entry: Entry = { input: { ...input, signal }, sender: input.sender, bytes, order,
62
+ done: deferred<boolean>(), ownsRecall: false, detach: () => {} };
63
+ const cancel = () => {
64
+ const index = scene!.pending.indexOf(entry);
65
+ if (index < 0) return;
66
+ scene!.pending.splice(index, 1); scene!.bytes -= bytes;
67
+ input.audit({ event: 'group_review_cancelled', messageId: input.messageId });
68
+ this.#finish(entry);
69
+ };
70
+ signal.addEventListener('abort', cancel, { once: true });
71
+ entry.detach = () => signal.removeEventListener('abort', cancel);
72
+ scene.pending.push(entry); scene.pending.sort((a, b) => a.order - b.order); scene.bytes += bytes;
73
+ scene.active.set(key, entry);
74
+ if (!this.#running) {
75
+ if (takeGroupBatch(scene.pending).length === REQUEST_BATCHES) this.#start();
76
+ else if (!this.#timer) {
77
+ this.#timer = setTimeout(() => this.#start(), COLLECT_MS);
78
+ this.#timer.unref();
79
+ }
80
+ }
81
+ return { done: entry.done.promise };
82
+ }
83
+
84
+ #assertActive(): void {
85
+ this.#abort.signal.throwIfAborted();
86
+ if (!this.#gate.active) throw new Error('Group review generation is not active');
87
+ }
88
+
89
+ #start(): void {
90
+ clearTimeout(this.#timer); this.#timer = undefined;
91
+ if (this.#running || this.#abort.signal.aborted) return;
92
+ this.#running = true;
93
+ void this.#drain().finally(() => {
94
+ this.#running = false;
95
+ if (!this.#abort.signal.aborted && [...this.#scenes.values()].some(scene => scene.pending.length)) this.#start();
96
+ });
97
+ }
98
+
99
+ async #drain(): Promise<void> {
100
+ while (!this.#abort.signal.aborted) {
101
+ const scene = [...this.#scenes.values()].filter(item => item.pending.length)
102
+ .sort((a, b) => a.pending[0]!.order - b.pending[0]!.order)[0];
103
+ if (!scene) return;
104
+ // Each finite batch owns the current snapshot after its inbound Message expires. A retained
105
+ // queue must not pin an old generation throughout a continuously busy drain loop.
106
+ const release = this.#gate.acquire();
107
+ if (!release) return;
108
+ // Preserve FIFO across binding replacement; a request must use exactly one completion resource.
109
+ const completion = scene.pending[0]!.input.completion;
110
+ const boundary = scene.pending.findIndex(entry => entry.input.completion !== completion);
111
+ const compatible = boundary < 0 ? scene.pending : scene.pending.slice(0, boundary);
112
+ const entries = takeGroupBatch(compatible).flat().sort((a, b) => a.order - b.order);
113
+ scene.pending.splice(0, entries.length); scene.bytes -= entries.reduce((sum, item) => sum + item.bytes, 0);
114
+ try {
115
+ await reviewGroupBatch(this.#moderator, entries.map(entry => ({ ...entry.input,
116
+ onReview: () => { entry.ownsRecall = true; entry.input.onReview?.(); },
117
+ })), this.#abort.signal);
118
+ } catch (error) {
119
+ for (const entry of entries) entry.input.audit({ event: 'group_review_batch_failed', messageId: entry.input.messageId,
120
+ error: error instanceof Error ? error.message : String(error) });
121
+ } finally {
122
+ for (const entry of entries) this.#finish(entry);
123
+ release();
124
+ }
125
+ }
126
+ }
127
+
128
+ #finish(entry: Entry): void {
129
+ entry.detach();
130
+ const active = this.#scenes.get(entry.input.scope)?.active;
131
+ const key = JSON.stringify([entry.sender, entry.input.messageId]);
132
+ if (active?.get(key) === entry) active.delete(key);
133
+ entry.done.resolve(entry.ownsRecall);
134
+ }
135
+ }
package/src/types.ts CHANGED
@@ -92,6 +92,8 @@ export interface HttpSourceConfig {
92
92
  export type SourceConfig = LocalSourceConfig | HttpSourceConfig;
93
93
 
94
94
  export interface ModerationConfig {
95
+ /** Empty unless an operator explicitly enables automatic group moderation. */
96
+ readonly groupModeration: readonly GroupModerationTarget[];
95
97
  readonly enabled: boolean;
96
98
  readonly onError: OnErrorPolicy;
97
99
  readonly maskChar: string;
@@ -113,6 +115,13 @@ export interface ModerationConfig {
113
115
  readonly sources: readonly SourceConfig[];
114
116
  }
115
117
 
118
+ export interface GroupModerationTarget {
119
+ readonly adapter: string;
120
+ readonly endpoint: string;
121
+ readonly account: string;
122
+ readonly group: string;
123
+ }
124
+
116
125
  export const SEVERITY_RANK: Readonly<Record<Severity, number>> = Object.freeze({
117
126
  pass: 0,
118
127
  low: 1,