@meith/moderation 0.16.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.
- package/LICENSE.md +165 -0
- package/package.json +25 -0
- package/src/index.ts +101 -0
- package/src/inline.ts +269 -0
- package/src/modcp.ts +174 -0
- package/src/queue.ts +149 -0
- package/src/reports.ts +260 -0
- package/src/surgery.ts +175 -0
- package/src/thread-tools.ts +295 -0
- package/src/warnings.ts +386 -0
package/src/warnings.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
import { ValidationError } from '@meith/core'
|
|
2
|
+
import { msg } from '@meith/i18n'
|
|
3
|
+
|
|
4
|
+
export type WarningAction = 'suspend_posting' | 'moderate_posting' | 'ban'
|
|
5
|
+
|
|
6
|
+
export const WARNING_ACTIONS: readonly WarningAction[] = [
|
|
7
|
+
'suspend_posting',
|
|
8
|
+
'moderate_posting',
|
|
9
|
+
'ban',
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
export const REASON_MAX = 2000
|
|
13
|
+
export const TITLE_MAX = 150
|
|
14
|
+
export const POINTS_MAX = 100
|
|
15
|
+
export const WARNINGS_PAGE_SIZE = 25
|
|
16
|
+
|
|
17
|
+
export interface WarningType {
|
|
18
|
+
readonly id: number
|
|
19
|
+
readonly title: string
|
|
20
|
+
readonly points: number
|
|
21
|
+
readonly expiryDays: number | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface WarningLevel {
|
|
25
|
+
readonly points: number
|
|
26
|
+
readonly action: WarningAction
|
|
27
|
+
readonly durationDays: number | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface WarningRow {
|
|
31
|
+
readonly id: number
|
|
32
|
+
readonly userId: number
|
|
33
|
+
readonly title: string
|
|
34
|
+
readonly points: number
|
|
35
|
+
readonly reason: string
|
|
36
|
+
readonly issuedByUserId: number | null
|
|
37
|
+
readonly issuedByUsername: string | null
|
|
38
|
+
readonly postId: number | null
|
|
39
|
+
readonly createdAt: Date
|
|
40
|
+
readonly expiresAt: Date | null
|
|
41
|
+
readonly revokedAt: Date | null
|
|
42
|
+
readonly revokedByUsername: string | null
|
|
43
|
+
readonly revokeReason: string | null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface WarningPage {
|
|
47
|
+
readonly rows: readonly WarningRow[]
|
|
48
|
+
readonly nextCursor?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface WarningStanding {
|
|
52
|
+
readonly userId: number
|
|
53
|
+
readonly points: number
|
|
54
|
+
readonly level: WarningLevel | null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface PostingRestriction {
|
|
58
|
+
readonly suspendedUntil: Date | null
|
|
59
|
+
readonly moderatedUntil: Date | null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const NO_RESTRICTION: PostingRestriction = {
|
|
63
|
+
suspendedUntil: null,
|
|
64
|
+
moderatedUntil: null,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface IssuedWarning {
|
|
68
|
+
readonly warningId: number
|
|
69
|
+
readonly points: number
|
|
70
|
+
readonly standing: WarningStanding
|
|
71
|
+
readonly triggered: WarningLevel | null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface WarningRepository {
|
|
75
|
+
listTypes(): Promise<readonly WarningType[]>
|
|
76
|
+
listLevels(): Promise<readonly WarningLevel[]>
|
|
77
|
+
|
|
78
|
+
findType(typeId: number): Promise<WarningType | null>
|
|
79
|
+
|
|
80
|
+
findWarnable(userId: number): Promise<{ id: number; username: string } | null>
|
|
81
|
+
|
|
82
|
+
findPostAuthor(postId: number): Promise<number | null>
|
|
83
|
+
|
|
84
|
+
issue(input: {
|
|
85
|
+
readonly userId: number
|
|
86
|
+
readonly issuedByUserId: number
|
|
87
|
+
readonly typeId: number | null
|
|
88
|
+
readonly title: string
|
|
89
|
+
readonly points: number
|
|
90
|
+
readonly reason: string
|
|
91
|
+
readonly postId: number | null
|
|
92
|
+
readonly expiresAt: Date | null
|
|
93
|
+
readonly at: Date
|
|
94
|
+
}): Promise<{ warningId: number; points: number }>
|
|
95
|
+
|
|
96
|
+
revoke(input: {
|
|
97
|
+
readonly warningId: number
|
|
98
|
+
readonly actorUserId: number
|
|
99
|
+
readonly reason: string
|
|
100
|
+
readonly at: Date
|
|
101
|
+
}): Promise<{ userId: number; points: number } | null>
|
|
102
|
+
|
|
103
|
+
pointsFor(userId: number): Promise<number>
|
|
104
|
+
|
|
105
|
+
readRestriction(userId: number): Promise<PostingRestriction>
|
|
106
|
+
|
|
107
|
+
applyRestriction(input: {
|
|
108
|
+
readonly userId: number
|
|
109
|
+
readonly restriction: PostingRestriction
|
|
110
|
+
readonly at: Date
|
|
111
|
+
}): Promise<void>
|
|
112
|
+
|
|
113
|
+
history(
|
|
114
|
+
userId: number,
|
|
115
|
+
options: { readonly limit: number; readonly after?: string },
|
|
116
|
+
): Promise<WarningPage>
|
|
117
|
+
|
|
118
|
+
expireDue(now: Date, limit: number): Promise<{ expired: number; userIds: readonly number[] }>
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export interface WarningBanPort {
|
|
122
|
+
ban(input: {
|
|
123
|
+
readonly userId: number
|
|
124
|
+
readonly bannedByUserId: number
|
|
125
|
+
readonly reason: string
|
|
126
|
+
readonly publicReason: string
|
|
127
|
+
readonly expiresAt?: Date | undefined
|
|
128
|
+
}): Promise<void>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface WarningNotifierPort {
|
|
132
|
+
warned(input: {
|
|
133
|
+
readonly userId: number
|
|
134
|
+
readonly title: string
|
|
135
|
+
readonly points: number
|
|
136
|
+
readonly totalPoints: number
|
|
137
|
+
readonly reason: string
|
|
138
|
+
readonly restriction: WarningAction | null
|
|
139
|
+
}): Promise<void>
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class WarningService {
|
|
143
|
+
private readonly warnings: WarningRepository
|
|
144
|
+
private readonly bans: WarningBanPort | null
|
|
145
|
+
private readonly notifier: WarningNotifierPort | null
|
|
146
|
+
private readonly now: () => Date
|
|
147
|
+
|
|
148
|
+
constructor(deps: {
|
|
149
|
+
warnings: WarningRepository
|
|
150
|
+
bans?: WarningBanPort | null
|
|
151
|
+
notifier?: WarningNotifierPort | null
|
|
152
|
+
now?: () => Date
|
|
153
|
+
}) {
|
|
154
|
+
this.warnings = deps.warnings
|
|
155
|
+
this.bans = deps.bans ?? null
|
|
156
|
+
this.notifier = deps.notifier ?? null
|
|
157
|
+
this.now = deps.now ?? (() => new Date())
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async listTypes(): Promise<readonly WarningType[]> {
|
|
161
|
+
return this.warnings.listTypes()
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async history(userId: number, options: { readonly after?: string } = {}): Promise<WarningPage> {
|
|
165
|
+
return this.warnings.history(userId, {
|
|
166
|
+
limit: WARNINGS_PAGE_SIZE,
|
|
167
|
+
...(options.after === undefined ? {} : { after: options.after }),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async issue(input: {
|
|
172
|
+
readonly userId: number
|
|
173
|
+
readonly actorUserId: number
|
|
174
|
+
readonly typeId: number | null
|
|
175
|
+
readonly title?: string | undefined
|
|
176
|
+
readonly points?: number | undefined
|
|
177
|
+
readonly reason: string
|
|
178
|
+
readonly postId?: number | null
|
|
179
|
+
readonly mayWarn: boolean
|
|
180
|
+
}): Promise<IssuedWarning> {
|
|
181
|
+
if (!input.mayWarn) throw new ValidationError(msg('error.moderation.warn-members'))
|
|
182
|
+
|
|
183
|
+
if (input.userId === input.actorUserId) {
|
|
184
|
+
throw new ValidationError(msg('error.moderation.warn-yourself'))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const target = await this.warnings.findWarnable(input.userId)
|
|
188
|
+
if (target === null) throw new ValidationError(msg('error.moderation.member-exist'))
|
|
189
|
+
|
|
190
|
+
const reason = input.reason.trim()
|
|
191
|
+
if (reason.length === 0) throw new ValidationError(msg('error.moderation.say-what-warning-for'))
|
|
192
|
+
if (reason.length > REASON_MAX) {
|
|
193
|
+
throw new ValidationError(msg('error.moderation.reason-length', { max: REASON_MAX }))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const { title, points, expiryDays } = await this.resolveSubject(input)
|
|
197
|
+
|
|
198
|
+
const at = this.now()
|
|
199
|
+
const before = await this.standingFor(input.userId)
|
|
200
|
+
|
|
201
|
+
const written = await this.warnings.issue({
|
|
202
|
+
userId: input.userId,
|
|
203
|
+
issuedByUserId: input.actorUserId,
|
|
204
|
+
typeId: input.typeId,
|
|
205
|
+
title,
|
|
206
|
+
points,
|
|
207
|
+
reason,
|
|
208
|
+
postId: input.postId ?? null,
|
|
209
|
+
expiresAt: expiryDays === null ? null : addDays(at, expiryDays),
|
|
210
|
+
at,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
const after = await this.standingFrom(written.points)
|
|
214
|
+
const triggered = newlyReached(before.level, after.level)
|
|
215
|
+
await this.enforce(input.userId, after, at, input.actorUserId, triggered)
|
|
216
|
+
|
|
217
|
+
if (this.notifier !== null) {
|
|
218
|
+
await this.notifier
|
|
219
|
+
.warned({
|
|
220
|
+
userId: input.userId,
|
|
221
|
+
title,
|
|
222
|
+
points,
|
|
223
|
+
totalPoints: written.points,
|
|
224
|
+
reason,
|
|
225
|
+
restriction: triggered?.action ?? null,
|
|
226
|
+
})
|
|
227
|
+
.catch(() => undefined)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return {
|
|
231
|
+
warningId: written.warningId,
|
|
232
|
+
points: written.points,
|
|
233
|
+
standing: { userId: input.userId, points: written.points, level: after.level },
|
|
234
|
+
triggered,
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async revoke(input: {
|
|
239
|
+
readonly warningId: number
|
|
240
|
+
readonly actorUserId: number
|
|
241
|
+
readonly reason: string
|
|
242
|
+
readonly mayWarn: boolean
|
|
243
|
+
}): Promise<WarningStanding | null> {
|
|
244
|
+
if (!input.mayWarn) throw new ValidationError(msg('error.moderation.revoke-warnings'))
|
|
245
|
+
|
|
246
|
+
const reason = input.reason.trim()
|
|
247
|
+
if (reason.length > REASON_MAX) {
|
|
248
|
+
throw new ValidationError(msg('error.moderation.reason-length', { max: REASON_MAX }))
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const at = this.now()
|
|
252
|
+
const revoked = await this.warnings.revoke({
|
|
253
|
+
warningId: input.warningId,
|
|
254
|
+
actorUserId: input.actorUserId,
|
|
255
|
+
reason,
|
|
256
|
+
at,
|
|
257
|
+
})
|
|
258
|
+
if (revoked === null) return null
|
|
259
|
+
|
|
260
|
+
const standing = await this.standingFrom(revoked.points)
|
|
261
|
+
await this.enforce(revoked.userId, standing, at, input.actorUserId, null)
|
|
262
|
+
|
|
263
|
+
return { userId: revoked.userId, points: revoked.points, level: standing.level }
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async expireDue(limit = 200): Promise<number> {
|
|
267
|
+
const at = this.now()
|
|
268
|
+
const { expired, userIds } = await this.warnings.expireDue(at, limit)
|
|
269
|
+
|
|
270
|
+
for (const userId of userIds) {
|
|
271
|
+
const points = await this.warnings.pointsFor(userId)
|
|
272
|
+
const standing = await this.standingFrom(points)
|
|
273
|
+
await this.enforce(userId, standing, at, null, null)
|
|
274
|
+
}
|
|
275
|
+
return expired
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async standingFor(userId: number): Promise<WarningStanding> {
|
|
279
|
+
const points = await this.warnings.pointsFor(userId)
|
|
280
|
+
const standing = await this.standingFrom(points)
|
|
281
|
+
return { userId, points, level: standing.level }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private async resolveSubject(input: {
|
|
285
|
+
readonly typeId: number | null
|
|
286
|
+
readonly title?: string | undefined
|
|
287
|
+
readonly points?: number | undefined
|
|
288
|
+
}): Promise<{ title: string; points: number; expiryDays: number | null }> {
|
|
289
|
+
if (input.typeId !== null) {
|
|
290
|
+
const type = await this.warnings.findType(input.typeId)
|
|
291
|
+
if (type === null) throw new ValidationError(msg('error.moderation.warning-type-exist'))
|
|
292
|
+
return { title: type.title, points: type.points, expiryDays: type.expiryDays }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const title = (input.title ?? '').trim()
|
|
296
|
+
if (title.length === 0) throw new ValidationError(msg('error.moderation.warning-needs-title'))
|
|
297
|
+
if (title.length > TITLE_MAX) {
|
|
298
|
+
throw new ValidationError(msg('error.moderation.warning-title-length', { max: TITLE_MAX }))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const points = input.points ?? 0
|
|
302
|
+
if (!Number.isSafeInteger(points) || points < 1 || points > POINTS_MAX) {
|
|
303
|
+
throw new ValidationError(msg('error.moderation.points-range', { max: POINTS_MAX }))
|
|
304
|
+
}
|
|
305
|
+
return { title, points, expiryDays: null }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private async standingFrom(points: number): Promise<{ level: WarningLevel | null }> {
|
|
309
|
+
const levels = await this.warnings.listLevels()
|
|
310
|
+
let reached: WarningLevel | null = null
|
|
311
|
+
for (const level of levels) {
|
|
312
|
+
if (points >= level.points && (reached === null || level.points > reached.points)) {
|
|
313
|
+
reached = level
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return { level: reached }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async enforce(
|
|
320
|
+
userId: number,
|
|
321
|
+
standing: { level: WarningLevel | null },
|
|
322
|
+
at: Date,
|
|
323
|
+
actorUserId: number | null,
|
|
324
|
+
triggered: WarningLevel | null,
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
const level = standing.level
|
|
327
|
+
const restriction: PostingRestriction =
|
|
328
|
+
level === null || level.action === 'ban'
|
|
329
|
+
? NO_RESTRICTION
|
|
330
|
+
: {
|
|
331
|
+
suspendedUntil:
|
|
332
|
+
level.action === 'suspend_posting' ? until(at, level.durationDays) : null,
|
|
333
|
+
moderatedUntil:
|
|
334
|
+
level.action === 'moderate_posting' ? until(at, level.durationDays) : null,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
await this.warnings.applyRestriction({ userId, restriction, at })
|
|
338
|
+
|
|
339
|
+
if (triggered?.action === 'ban' && this.bans !== null && actorUserId !== null) {
|
|
340
|
+
await this.bans
|
|
341
|
+
.ban({
|
|
342
|
+
userId,
|
|
343
|
+
bannedByUserId: actorUserId,
|
|
344
|
+
reason: `Reached ${triggered.points} warning points.`,
|
|
345
|
+
publicReason: 'Repeated breaches of the board rules.',
|
|
346
|
+
...(triggered.durationDays === null
|
|
347
|
+
? {}
|
|
348
|
+
: { expiresAt: addDays(at, triggered.durationDays) }),
|
|
349
|
+
})
|
|
350
|
+
.catch(() => undefined)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function newlyReached(
|
|
356
|
+
before: WarningLevel | null,
|
|
357
|
+
after: WarningLevel | null,
|
|
358
|
+
): WarningLevel | null {
|
|
359
|
+
if (after === null) return null
|
|
360
|
+
if (before !== null && before.points >= after.points) return null
|
|
361
|
+
return after
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function until(at: Date, days: number | null): Date | null {
|
|
365
|
+
return days === null ? FOREVER : addDays(at, days)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const FOREVER = new Date('9999-12-31T00:00:00Z')
|
|
369
|
+
|
|
370
|
+
function addDays(at: Date, days: number): Date {
|
|
371
|
+
return new Date(at.getTime() + days * 86_400_000)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function restrictsPosting(
|
|
375
|
+
restriction: PostingRestriction,
|
|
376
|
+
now: Date,
|
|
377
|
+
): { suspended: boolean; moderated: boolean } {
|
|
378
|
+
return {
|
|
379
|
+
suspended: restriction.suspendedUntil !== null && restriction.suspendedUntil > now,
|
|
380
|
+
moderated: restriction.moderatedUntil !== null && restriction.moderatedUntil > now,
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function parseWarningAction(value: string): WarningAction | null {
|
|
385
|
+
return WARNING_ACTIONS.includes(value as WarningAction) ? (value as WarningAction) : null
|
|
386
|
+
}
|