@atcute/bluesky-moderation 1.0.1

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.
Files changed (55) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +174 -0
  3. package/dist/behaviors.d.ts +41 -0
  4. package/dist/behaviors.js +56 -0
  5. package/dist/behaviors.js.map +1 -0
  6. package/dist/decision.d.ts +85 -0
  7. package/dist/decision.js +214 -0
  8. package/dist/decision.js.map +1 -0
  9. package/dist/index.d.ts +12 -0
  10. package/dist/index.js +13 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/internal/keyword-filter.d.ts +8 -0
  13. package/dist/internal/keyword-filter.js +27 -0
  14. package/dist/internal/keyword-filter.js.map +1 -0
  15. package/dist/keyword-filter.d.ts +26 -0
  16. package/dist/keyword-filter.js +61 -0
  17. package/dist/keyword-filter.js.map +1 -0
  18. package/dist/label.d.ts +73 -0
  19. package/dist/label.js +286 -0
  20. package/dist/label.js.map +1 -0
  21. package/dist/subjects/feed-generator.d.ts +3 -0
  22. package/dist/subjects/feed-generator.js +10 -0
  23. package/dist/subjects/feed-generator.js.map +1 -0
  24. package/dist/subjects/list.d.ts +3 -0
  25. package/dist/subjects/list.js +27 -0
  26. package/dist/subjects/list.js.map +1 -0
  27. package/dist/subjects/notification.d.ts +3 -0
  28. package/dist/subjects/notification.js +10 -0
  29. package/dist/subjects/notification.js.map +1 -0
  30. package/dist/subjects/post.d.ts +3 -0
  31. package/dist/subjects/post.js +215 -0
  32. package/dist/subjects/post.js.map +1 -0
  33. package/dist/subjects/profile.d.ts +3 -0
  34. package/dist/subjects/profile.js +27 -0
  35. package/dist/subjects/profile.js.map +1 -0
  36. package/dist/types.d.ts +39 -0
  37. package/dist/types.js +2 -0
  38. package/dist/types.js.map +1 -0
  39. package/dist/ui.d.ts +10 -0
  40. package/dist/ui.js +145 -0
  41. package/dist/ui.js.map +1 -0
  42. package/lib/behaviors.ts +65 -0
  43. package/lib/decision.ts +363 -0
  44. package/lib/index.ts +63 -0
  45. package/lib/internal/keyword-filter.ts +45 -0
  46. package/lib/keyword-filter.ts +95 -0
  47. package/lib/label.ts +353 -0
  48. package/lib/subjects/feed-generator.ts +22 -0
  49. package/lib/subjects/list.ts +41 -0
  50. package/lib/subjects/notification.ts +22 -0
  51. package/lib/subjects/post.ts +316 -0
  52. package/lib/subjects/profile.ts +42 -0
  53. package/lib/types.ts +61 -0
  54. package/lib/ui.ts +183 -0
  55. package/package.json +36 -0
@@ -0,0 +1,363 @@
1
+ import type { AppBskyGraphDefs, At } from '@atcute/client/lexicons';
2
+
3
+ import { DisplayContext, ModerationAction, type BehaviorMapping, type LabelTarget } from './behaviors.js';
4
+ import type { Label, LabelerPreference, ModerationOptions } from './types.js';
5
+
6
+ import type { KeywordFilter } from './keyword-filter.js';
7
+ import {
8
+ BUILTIN_LABELS,
9
+ isCustomLabelValue,
10
+ LabelFlags,
11
+ LabelPreference,
12
+ type InterpretedLabelDefinition,
13
+ } from './label.js';
14
+
15
+ const enum ModerationSeverity {
16
+ High = 1,
17
+ Medium = 2,
18
+ Low = 3,
19
+ }
20
+
21
+ export const enum ModerationCauseType {
22
+ /** caused by a label */
23
+ Label = 1,
24
+ /** caused by viewer blocking the subject */
25
+ Blocking = 2,
26
+ /** caused by subject blocking the viewer */
27
+ BlockedBy = 3,
28
+ /** caused by viewer having a (permanent) mute on subject */
29
+ MutedPermanent = 4,
30
+ /** caused by a temporary mute */
31
+ MutedTemporary = 5,
32
+ /** caused by a keyword mute */
33
+ MutedKeyword = 6,
34
+ /** caused by a hidden post */
35
+ Hidden = 7,
36
+ }
37
+
38
+ export interface BlockingModerationCause {
39
+ type: ModerationCauseType.Blocking;
40
+ priority: 3;
41
+ source: AppBskyGraphDefs.ListViewBasic | null;
42
+
43
+ downgraded: boolean;
44
+ }
45
+
46
+ export interface BlockedByModerationCause {
47
+ type: ModerationCauseType.BlockedBy;
48
+ priority: 4;
49
+
50
+ downgraded: boolean;
51
+ }
52
+
53
+ export interface HiddenModerationCause {
54
+ type: ModerationCauseType.Hidden;
55
+ priority: 6;
56
+
57
+ downgraded: boolean;
58
+ }
59
+
60
+ export interface LabelModerationCause {
61
+ type: ModerationCauseType.Label;
62
+ priority: 1 | 2 | 5 | 7 | 8;
63
+ source: At.Did | null;
64
+
65
+ label: Label;
66
+ labelDef: InterpretedLabelDefinition;
67
+
68
+ target: LabelTarget;
69
+ pref: LabelPreference;
70
+ behavior: BehaviorMapping;
71
+
72
+ noOverride: boolean;
73
+ downgraded: boolean;
74
+ }
75
+
76
+ export interface MutedPermanentModerationCause {
77
+ type: ModerationCauseType.MutedPermanent;
78
+ priority: 6;
79
+ source: AppBskyGraphDefs.ListViewBasic | null;
80
+
81
+ downgraded: boolean;
82
+ }
83
+
84
+ export interface MutedTemporaryModerationCause {
85
+ type: ModerationCauseType.MutedTemporary;
86
+ priority: 6;
87
+
88
+ downgraded: boolean;
89
+ }
90
+
91
+ export interface MutedKeywordModerationCause {
92
+ type: ModerationCauseType.MutedKeyword;
93
+ priority: 6;
94
+ source: KeywordFilter;
95
+
96
+ downgraded: boolean;
97
+ }
98
+
99
+ export type ModerationCause =
100
+ | BlockedByModerationCause
101
+ | BlockingModerationCause
102
+ | HiddenModerationCause
103
+ | LabelModerationCause
104
+ | MutedKeywordModerationCause
105
+ | MutedPermanentModerationCause
106
+ | MutedTemporaryModerationCause;
107
+
108
+ export interface ModerationDecision {
109
+ authorDid: At.Did;
110
+ isMe: boolean;
111
+ causes: ModerationCause[];
112
+ }
113
+
114
+ export const createModerationDecision = (
115
+ authorDid: At.Did,
116
+ { viewerDid }: ModerationOptions,
117
+ ): ModerationDecision => {
118
+ return {
119
+ authorDid: authorDid,
120
+ isMe: authorDid === viewerDid,
121
+ causes: [],
122
+ };
123
+ };
124
+
125
+ type FalsyValue = 0 | '' | false | null | undefined;
126
+ export const mergeModerationDecisions = (
127
+ ...sources: [ModerationDecision, ModerationDecision | FalsyValue, ...(ModerationDecision | FalsyValue)[]]
128
+ ): ModerationDecision => {
129
+ return {
130
+ authorDid: sources[0].authorDid,
131
+ isMe: sources[0].isMe,
132
+ causes: sources.filter((source) => !!source).flatMap((source) => source.causes),
133
+ };
134
+ };
135
+
136
+ export const considerHidden = (decision: ModerationDecision): void => {
137
+ decision.causes.push({
138
+ type: ModerationCauseType.Hidden,
139
+ priority: 6,
140
+
141
+ downgraded: false,
142
+ });
143
+ };
144
+
145
+ export const considerBlocking = (
146
+ decision: ModerationDecision,
147
+ source: AppBskyGraphDefs.ListViewBasic | null,
148
+ ): void => {
149
+ if (decision.isMe) {
150
+ return;
151
+ }
152
+
153
+ decision.causes.push({
154
+ type: ModerationCauseType.Blocking,
155
+ priority: 3,
156
+ source: source,
157
+
158
+ downgraded: false,
159
+ });
160
+ };
161
+
162
+ export const considerBlockedBy = (decision: ModerationDecision): void => {
163
+ if (decision.isMe) {
164
+ return;
165
+ }
166
+
167
+ decision.causes.push({
168
+ type: ModerationCauseType.BlockedBy,
169
+ priority: 4,
170
+
171
+ downgraded: false,
172
+ });
173
+ };
174
+
175
+ export const considerPermanentMute = (
176
+ decision: ModerationDecision,
177
+ source: AppBskyGraphDefs.ListViewBasic | null,
178
+ ): void => {
179
+ if (decision.isMe) {
180
+ return;
181
+ }
182
+
183
+ decision.causes.push({
184
+ type: ModerationCauseType.MutedPermanent,
185
+ priority: 6,
186
+ source: source,
187
+
188
+ downgraded: false,
189
+ });
190
+ };
191
+
192
+ export const considerTemporaryMute = (decision: ModerationDecision, { prefs }: ModerationOptions): void => {
193
+ if (decision.isMe) {
194
+ return;
195
+ }
196
+
197
+ if (prefs.temporaryMutes?.includes(decision.authorDid)) {
198
+ decision.causes.push({
199
+ type: ModerationCauseType.MutedTemporary,
200
+ priority: 6,
201
+
202
+ downgraded: false,
203
+ });
204
+ }
205
+ };
206
+
207
+ export const considerKeywordMute = (decision: ModerationDecision, source: KeywordFilter): void => {
208
+ decision.causes.push({
209
+ type: ModerationCauseType.MutedKeyword,
210
+ priority: 6,
211
+ source: source,
212
+
213
+ downgraded: false,
214
+ });
215
+ };
216
+
217
+ export const considerLabels = (
218
+ decision: ModerationDecision,
219
+ target: LabelTarget,
220
+ labels: Label[] | undefined,
221
+ opts: ModerationOptions,
222
+ ): void => {
223
+ if (labels?.length) {
224
+ for (let idx = 0, len = labels.length; idx < len; idx++) {
225
+ considerLabel(decision, target, labels[idx], opts);
226
+ }
227
+ }
228
+ };
229
+
230
+ export const considerLabel = (
231
+ decision: ModerationDecision,
232
+ target: LabelTarget,
233
+ label: Label,
234
+ { viewerDid, prefs, labelDefs }: ModerationOptions,
235
+ ): void => {
236
+ const { src, val } = label;
237
+
238
+ /// 1. grab the label definition
239
+ let labelDef: InterpretedLabelDefinition | undefined;
240
+
241
+ if (isCustomLabelValue(val)) {
242
+ labelDef = labelDefs?.[src]?.[val];
243
+ }
244
+ if (labelDef === undefined) {
245
+ labelDef = BUILTIN_LABELS[val];
246
+ }
247
+
248
+ if (labelDef === undefined) {
249
+ return;
250
+ }
251
+
252
+ /// 2. check applicability
253
+ if (labelDef.flags & LabelFlags.UnauthenticatedOnly && viewerDid !== undefined) {
254
+ // skip if label is only for signed-out users
255
+ return;
256
+ }
257
+
258
+ const isConfigurable = !(labelDef.flags & LabelFlags.NoConfigurable);
259
+ const isAdultOnly = !!(labelDef.flags & LabelFlags.AdultOnly);
260
+
261
+ const isSelfApplied = src === decision.authorDid;
262
+
263
+ let labelerPref: LabelerPreference | undefined;
264
+ if (!isSelfApplied) {
265
+ labelerPref = prefs.prefsByLabelers?.[src];
266
+
267
+ if (labelerPref === undefined) {
268
+ // skip if we're looking at an unconfigured labeler
269
+ return;
270
+ }
271
+ } else {
272
+ if (labelDef.flags & LabelFlags.NoSelf) {
273
+ // skip if label is not allowed to be self-applied
274
+ return;
275
+ }
276
+ }
277
+
278
+ let pref: LabelPreference;
279
+ if (isConfigurable) {
280
+ if (isAdultOnly && !prefs.adultContentEnabled) {
281
+ pref = LabelPreference.Hide;
282
+ } else {
283
+ pref = labelerPref?.labelPrefs[val] ?? prefs.globalLabelPrefs?.[val] ?? labelDef.defaultPref;
284
+ }
285
+ } else {
286
+ pref = labelDef.defaultPref;
287
+ }
288
+
289
+ // skip if label is configured to ignore
290
+ if (pref === LabelPreference.Ignore) {
291
+ return;
292
+ }
293
+
294
+ const behavior = labelDef.behavior[target];
295
+
296
+ const severity = getModerationSeverity(behavior);
297
+ let priority: LabelModerationCause['priority'];
298
+
299
+ if (labelDef.flags & LabelFlags.NoOverride || (isAdultOnly && !prefs.adultContentEnabled)) {
300
+ priority = 1;
301
+ } else if (pref === LabelPreference.Hide) {
302
+ priority = 2;
303
+ } else if (severity === ModerationSeverity.High) {
304
+ priority = 5;
305
+ } else if (severity === ModerationSeverity.Medium) {
306
+ priority = 7;
307
+ } else {
308
+ priority = 8;
309
+ }
310
+
311
+ let noOverride = false;
312
+ if (labelDef.flags & LabelFlags.NoOverride || (isAdultOnly && !prefs.adultContentEnabled)) {
313
+ noOverride = true;
314
+ }
315
+
316
+ decision.causes.push({
317
+ type: ModerationCauseType.Label,
318
+ priority,
319
+ source: isSelfApplied ? label.src : null,
320
+
321
+ label: label,
322
+ labelDef: labelDef,
323
+
324
+ target: target,
325
+ pref: pref,
326
+ behavior: behavior,
327
+
328
+ noOverride: noOverride,
329
+ downgraded: false,
330
+ });
331
+ };
332
+
333
+ const getModerationSeverity = (behavior: BehaviorMapping) => {
334
+ if (
335
+ behavior[DisplayContext.ProfileView] === ModerationAction.Blur ||
336
+ behavior[DisplayContext.ContentView] === ModerationAction.Blur
337
+ ) {
338
+ return ModerationSeverity.High;
339
+ }
340
+
341
+ if (
342
+ behavior[DisplayContext.ContentList] === ModerationAction.Blur ||
343
+ behavior[DisplayContext.ContentMedia] === ModerationAction.Blur
344
+ ) {
345
+ return ModerationSeverity.Medium;
346
+ }
347
+
348
+ return ModerationSeverity.Low;
349
+ };
350
+
351
+ export const downgradeDecision = <T extends ModerationDecision | undefined>(decision: T): T => {
352
+ if (decision === undefined) {
353
+ return decision;
354
+ }
355
+
356
+ const causes = decision.causes;
357
+ for (let idx = 0, len = causes.length; idx < len; idx++) {
358
+ const cause = causes[idx];
359
+ cause.downgraded = true;
360
+ }
361
+
362
+ return decision;
363
+ };
package/lib/index.ts ADDED
@@ -0,0 +1,63 @@
1
+ import '@atcute/bluesky/lexicons';
2
+
3
+ export {
4
+ DisplayContext,
5
+ LabelTarget,
6
+ ModerationAction,
7
+ type BehaviorMapping,
8
+ type LabelBehaviorMatrix,
9
+ } from './behaviors.js';
10
+ export {
11
+ ModerationCauseType,
12
+ type BlockedByModerationCause,
13
+ type BlockingModerationCause,
14
+ type HiddenModerationCause,
15
+ type LabelModerationCause,
16
+ type ModerationCause,
17
+ type ModerationDecision,
18
+ type MutedKeywordModerationCause,
19
+ type MutedPermanentModerationCause,
20
+ type MutedTemporaryModerationCause,
21
+ } from './decision.js';
22
+
23
+ export {
24
+ createKeywordPattern,
25
+ interpretMutedWordPreference,
26
+ interpretMutedWordPreferences,
27
+ KeywordFilterFlags,
28
+ type KeywordFilter,
29
+ type KeywordMatch,
30
+ } from './keyword-filter.js';
31
+ export {
32
+ BlurLevel,
33
+ interpretLabelerDefinition,
34
+ interpretLabelerDefinitions,
35
+ interpretLabelValueDefinition,
36
+ isCustomLabelValue,
37
+ LabelFlags,
38
+ LabelPreference,
39
+ SeverityLevel,
40
+ type InterpretedLabelDefinition,
41
+ type InterpretedLabelMapping,
42
+ type LabelLocale,
43
+ } from './label.js';
44
+
45
+ export {
46
+ type FeedGeneratorSubject,
47
+ type Label,
48
+ type LabelerPreference,
49
+ type ListSubject,
50
+ type ModerationOptions,
51
+ type ModerationPreferences,
52
+ type NotificationSubject,
53
+ type PostSubject,
54
+ type ProfileSubject,
55
+ } from './types.js';
56
+
57
+ export { getDisplayRestrictions, type DisplayRestrictions } from './ui.js';
58
+
59
+ export { moderateFeedGenerator } from './subjects/feed-generator.js';
60
+ export { moderateList } from './subjects/list.js';
61
+ export { moderateNotification } from './subjects/notification.js';
62
+ export { moderatePost } from './subjects/post.js';
63
+ export { moderateProfile } from './subjects/profile.js';
@@ -0,0 +1,45 @@
1
+ import type { AppBskyActorDefs } from '@atcute/client/lexicons';
2
+
3
+ import { KeywordFilterFlags, type KeywordFilter } from '../keyword-filter.js';
4
+
5
+ const EMPTY_ARRAY: never[] = [];
6
+
7
+ export const matchesKeywordFilters = ({
8
+ filters,
9
+ text,
10
+ tags = EMPTY_ARRAY,
11
+ actor,
12
+ }: {
13
+ filters: KeywordFilter[];
14
+ text: string;
15
+ tags?: string[];
16
+ actor?: AppBskyActorDefs.ProfileView | AppBskyActorDefs.ProfileViewBasic;
17
+ }): KeywordFilter | null => {
18
+ for (let i = 0, il = filters.length; i < il; i++) {
19
+ const filter = filters[i];
20
+
21
+ if (actor && filter.flags & KeywordFilterFlags.NoFollowing) {
22
+ if (actor.viewer?.following) {
23
+ continue;
24
+ }
25
+ }
26
+
27
+ if (filter.flags & KeywordFilterFlags.ApplyTopic) {
28
+ for (let j = 0, jl = tags.length; j < jl; j++) {
29
+ const tag = tags[j];
30
+
31
+ if (filter.pattern.test(tag)) {
32
+ return filter;
33
+ }
34
+ }
35
+ }
36
+
37
+ if (filter.flags & KeywordFilterFlags.ApplyContent) {
38
+ if (filter.pattern.test(text)) {
39
+ return filter;
40
+ }
41
+ }
42
+ }
43
+
44
+ return null;
45
+ };
@@ -0,0 +1,95 @@
1
+ import type { AppBskyActorDefs } from '@atcute/client/lexicons';
2
+
3
+ const WORD_CHAR_RE = /^\w$/;
4
+ const WHITESPACE_RE = /\s+/;
5
+
6
+ export interface KeywordMatch {
7
+ /** keyword value */
8
+ value: string;
9
+ /** match whole keyword */
10
+ whole: boolean;
11
+ }
12
+
13
+ export const createKeywordPattern = (matchers: KeywordMatch | KeywordMatch[]): RegExp => {
14
+ if (!Array.isArray(matchers)) {
15
+ matchers = [matchers];
16
+ }
17
+
18
+ let re = '';
19
+
20
+ for (let idx = 0, len = matchers.length; idx < len; idx++) {
21
+ const match = matchers[idx];
22
+
23
+ const whole = match.whole;
24
+ let value = match.value;
25
+
26
+ value = value.trim();
27
+ if (value.length === 0) {
28
+ continue;
29
+ }
30
+
31
+ re && (re += '|');
32
+
33
+ if (whole && WORD_CHAR_RE.test(value.at(0)!)) {
34
+ re += '\\b';
35
+ }
36
+
37
+ re += escape(value).replace(WHITESPACE_RE, '\\s+');
38
+
39
+ if (whole && WORD_CHAR_RE.test(value.at(-1)!)) {
40
+ re += '\\b';
41
+ }
42
+ }
43
+
44
+ return new RegExp(re, 'i');
45
+ };
46
+
47
+ const ESCAPE_RE = /[.*+?^${}()|[\]\\]/g;
48
+ const escape = (str: string) => {
49
+ return str.replace(ESCAPE_RE, '\\$&');
50
+ };
51
+
52
+ export const enum KeywordFilterFlags {
53
+ /** filter applies to content */
54
+ ApplyContent = 1 << 0,
55
+ /** filter applies to tags */
56
+ ApplyTopic = 1 << 1,
57
+ /** filter shouldn't apply to following users */
58
+ NoFollowing = 1 << 2,
59
+ }
60
+
61
+ export interface KeywordFilter {
62
+ /** unique identifier for this filter */
63
+ id?: string;
64
+ /** pattern to match */
65
+ pattern: RegExp;
66
+ /** indicates how the filter should act */
67
+ flags: number;
68
+ }
69
+
70
+ export const interpretMutedWordPreference = (pref: AppBskyActorDefs.MutedWord): KeywordFilter => {
71
+ const { actorTarget, targets } = pref;
72
+
73
+ let flags = 0;
74
+
75
+ if (targets.includes('content')) {
76
+ flags |= KeywordFilterFlags.ApplyContent;
77
+ }
78
+ if (targets.includes('tag')) {
79
+ flags |= KeywordFilterFlags.ApplyTopic;
80
+ }
81
+
82
+ if (actorTarget === 'exclude-following') {
83
+ flags |= KeywordFilterFlags.NoFollowing;
84
+ }
85
+
86
+ return {
87
+ id: pref.id,
88
+ pattern: createKeywordPattern({ value: pref.value, whole: true }),
89
+ flags: flags,
90
+ };
91
+ };
92
+
93
+ export const interpretMutedWordPreferences = (pref: AppBskyActorDefs.MutedWordsPref): KeywordFilter[] => {
94
+ return pref.items.map((item) => interpretMutedWordPreference(item));
95
+ };