@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
package/lib/label.ts ADDED
@@ -0,0 +1,353 @@
1
+ import type { AppBskyLabelerDefs, At, ComAtprotoLabelDefs } from '@atcute/client/lexicons';
2
+
3
+ import { DisplayContext, LabelTarget, ModerationAction, type LabelBehaviorMatrix } from './behaviors.js';
4
+
5
+ export const enum LabelPreference {
6
+ /** ignore this label */
7
+ Ignore = 'ignore',
8
+ /** warn when viewing content or profile with this label */
9
+ Warn = 'warn',
10
+ /** hide content or profile containing this label */
11
+ Hide = 'hide',
12
+ }
13
+
14
+ export const enum LabelFlags {
15
+ /** no flags */
16
+ None = 0,
17
+
18
+ /** unblurring shouldn't be allowed */
19
+ NoOverride = 1 << 0,
20
+ /** label can't be configured */
21
+ NoConfigurable = 1 << 1,
22
+ /** label can't be applied as a self-label */
23
+ NoSelf = 1 << 2,
24
+ /** label is adult-only */
25
+ AdultOnly = 1 << 3,
26
+ /** label can't be applied if authenticated */
27
+ UnauthenticatedOnly = 1 << 4,
28
+ }
29
+
30
+ export const enum BlurLevel {
31
+ /** don't blur any parts of the content */
32
+ None = 'none',
33
+ /** only blur the media present in the content */
34
+ Media = 'media',
35
+ /** blur the entire content */
36
+ Content = 'content',
37
+
38
+ /** special blur value, guaranteed blurring of profile and content */
39
+ Forced = 'forced',
40
+ }
41
+
42
+ export const enum SeverityLevel {
43
+ /** don't inform the user */
44
+ None = 'none',
45
+ /** lightly inform the user about this label's presence */
46
+ Inform = 'inform',
47
+ /** alert the user about this label's presence */
48
+ Alert = 'alert',
49
+ }
50
+
51
+ export interface LabelLocale {
52
+ /** language code */
53
+ lang: string;
54
+ /** label name */
55
+ name: string;
56
+ /** label description */
57
+ description: string;
58
+ }
59
+
60
+ export interface InterpretedLabelDefinition {
61
+ /** label identifier */
62
+ identifier: string;
63
+ /** default preference */
64
+ defaultPref: LabelPreference;
65
+ /** how the content should be blurred */
66
+ blur: BlurLevel;
67
+ /** how the content should be informed */
68
+ severity: SeverityLevel;
69
+ /** additional flags for this label */
70
+ flags: number;
71
+ /** behavior matrix */
72
+ behavior: LabelBehaviorMatrix;
73
+ /** localization for the label */
74
+ locales: LabelLocale[];
75
+ }
76
+
77
+ export type InterpretedLabelMapping = Record<string, InterpretedLabelDefinition>;
78
+
79
+ export const getLabelBehaviorMatrix = (
80
+ blur: BlurLevel,
81
+ severity: SeverityLevel,
82
+ flags: number,
83
+ ): LabelBehaviorMatrix => {
84
+ const hasAdultFlag = !!(flags & LabelFlags.AdultOnly);
85
+
86
+ let alertOrInform: ModerationAction | undefined;
87
+ switch (severity) {
88
+ case SeverityLevel.Alert: {
89
+ alertOrInform = ModerationAction.Alert;
90
+ break;
91
+ }
92
+ case SeverityLevel.Inform: {
93
+ alertOrInform = ModerationAction.Inform;
94
+ break;
95
+ }
96
+ }
97
+
98
+ switch (blur) {
99
+ case BlurLevel.Forced: {
100
+ return {
101
+ [LabelTarget.Account]: {
102
+ [DisplayContext.ProfileList]: ModerationAction.Blur,
103
+ [DisplayContext.ProfileView]: ModerationAction.Blur,
104
+ [DisplayContext.ProfileMedia]: ModerationAction.Blur,
105
+ [DisplayContext.ContentList]: ModerationAction.Blur,
106
+ [DisplayContext.ContentView]: ModerationAction.Blur,
107
+ },
108
+ [LabelTarget.Profile]: {
109
+ [DisplayContext.ProfileMedia]: ModerationAction.Blur,
110
+ },
111
+ [LabelTarget.Content]: {
112
+ [DisplayContext.ContentList]: ModerationAction.Blur,
113
+ [DisplayContext.ContentView]: ModerationAction.Blur,
114
+ },
115
+ };
116
+ }
117
+ case BlurLevel.Content: {
118
+ return {
119
+ [LabelTarget.Account]: {
120
+ [DisplayContext.ProfileList]: alertOrInform,
121
+ [DisplayContext.ProfileView]: alertOrInform,
122
+
123
+ [DisplayContext.ContentList]: ModerationAction.Blur,
124
+ [DisplayContext.ContentView]: hasAdultFlag ? ModerationAction.Blur : alertOrInform,
125
+ },
126
+ [LabelTarget.Profile]: {
127
+ [DisplayContext.ProfileList]: alertOrInform,
128
+ [DisplayContext.ProfileView]: alertOrInform,
129
+ },
130
+ [LabelTarget.Content]: {
131
+ [DisplayContext.ContentList]: ModerationAction.Blur,
132
+ [DisplayContext.ContentView]: hasAdultFlag ? ModerationAction.Blur : alertOrInform,
133
+ },
134
+ };
135
+ }
136
+ case BlurLevel.Media: {
137
+ return {
138
+ [LabelTarget.Account]: {
139
+ [DisplayContext.ProfileList]: alertOrInform,
140
+ [DisplayContext.ProfileView]: alertOrInform,
141
+ [DisplayContext.ProfileMedia]: ModerationAction.Blur,
142
+ },
143
+ [LabelTarget.Profile]: {
144
+ [DisplayContext.ProfileList]: alertOrInform,
145
+ [DisplayContext.ProfileView]: alertOrInform,
146
+ [DisplayContext.ProfileMedia]: ModerationAction.Blur,
147
+ },
148
+ [LabelTarget.Content]: {
149
+ [DisplayContext.ContentMedia]: ModerationAction.Blur,
150
+ },
151
+ };
152
+ }
153
+ case BlurLevel.None: {
154
+ return {
155
+ [LabelTarget.Account]: {
156
+ [DisplayContext.ProfileList]: alertOrInform,
157
+ [DisplayContext.ProfileView]: alertOrInform,
158
+
159
+ [DisplayContext.ContentList]: alertOrInform,
160
+ [DisplayContext.ContentView]: alertOrInform,
161
+ },
162
+ [LabelTarget.Profile]: {
163
+ [DisplayContext.ProfileList]: alertOrInform,
164
+ [DisplayContext.ProfileView]: alertOrInform,
165
+ },
166
+ [LabelTarget.Content]: {
167
+ [DisplayContext.ContentList]: alertOrInform,
168
+ [DisplayContext.ContentView]: alertOrInform,
169
+ },
170
+ };
171
+ }
172
+ }
173
+ };
174
+
175
+ const FORCED_BEHAVIOR = /*#__PURE__*/ getLabelBehaviorMatrix(BlurLevel.Forced, SeverityLevel.None, 0);
176
+ const NSFW_BEHAVIOR = /*#__PURE__*/ getLabelBehaviorMatrix(BlurLevel.Media, SeverityLevel.None, 0);
177
+
178
+ export const BUILTIN_LABELS: InterpretedLabelMapping = {
179
+ '!hide': {
180
+ identifier: '!hide',
181
+ defaultPref: LabelPreference.Hide,
182
+ blur: BlurLevel.Forced,
183
+ severity: SeverityLevel.Alert,
184
+ flags: LabelFlags.NoOverride | LabelFlags.NoConfigurable | LabelFlags.NoSelf,
185
+ behavior: FORCED_BEHAVIOR,
186
+ locales: [],
187
+ },
188
+ '!warn': {
189
+ identifier: '!warn',
190
+ defaultPref: LabelPreference.Warn,
191
+ blur: BlurLevel.Forced,
192
+ severity: SeverityLevel.None,
193
+ flags: LabelFlags.NoConfigurable | LabelFlags.NoSelf,
194
+ behavior: FORCED_BEHAVIOR,
195
+ locales: [],
196
+ },
197
+ '!no-unauthenticated': {
198
+ identifier: '!no-unauthenticated',
199
+ defaultPref: LabelPreference.Hide,
200
+ blur: BlurLevel.Forced,
201
+ severity: SeverityLevel.None,
202
+ flags: LabelFlags.NoOverride | LabelFlags.NoConfigurable | LabelFlags.UnauthenticatedOnly,
203
+ behavior: FORCED_BEHAVIOR,
204
+ locales: [],
205
+ },
206
+
207
+ porn: {
208
+ identifier: 'porn',
209
+ defaultPref: LabelPreference.Warn,
210
+ blur: BlurLevel.Media,
211
+ severity: SeverityLevel.None,
212
+ flags: LabelFlags.AdultOnly,
213
+ behavior: NSFW_BEHAVIOR,
214
+ locales: [],
215
+ },
216
+ sexual: {
217
+ identifier: 'sexual',
218
+ defaultPref: LabelPreference.Warn,
219
+ blur: BlurLevel.Media,
220
+ severity: SeverityLevel.None,
221
+ flags: LabelFlags.AdultOnly,
222
+ behavior: NSFW_BEHAVIOR,
223
+ locales: [],
224
+ },
225
+ 'graphic-media': {
226
+ identifier: 'graphic-media',
227
+ defaultPref: LabelPreference.Warn,
228
+ blur: BlurLevel.Media,
229
+ severity: SeverityLevel.None,
230
+ flags: LabelFlags.AdultOnly,
231
+ behavior: NSFW_BEHAVIOR,
232
+ locales: [],
233
+ },
234
+ nudity: {
235
+ identifier: 'nudity',
236
+ defaultPref: LabelPreference.Warn,
237
+ blur: BlurLevel.Media,
238
+ severity: SeverityLevel.None,
239
+ flags: LabelFlags.AdultOnly,
240
+ behavior: NSFW_BEHAVIOR,
241
+ locales: [],
242
+ },
243
+ };
244
+
245
+ const CUSTOM_LABEL_RE = /^[a-z-]+$/;
246
+
247
+ export const isCustomLabelValue = (value: string): boolean => {
248
+ return CUSTOM_LABEL_RE.test(value);
249
+ };
250
+
251
+ export const interpretLabelValueDefinition = (
252
+ def: ComAtprotoLabelDefs.LabelValueDefinition,
253
+ ): InterpretedLabelDefinition => {
254
+ let defaultPref = LabelPreference.Warn;
255
+ let blur = BlurLevel.None;
256
+ let severity = SeverityLevel.None;
257
+ let flags = LabelFlags.NoSelf;
258
+
259
+ switch (def.blurs) {
260
+ case 'content': {
261
+ blur = BlurLevel.Content;
262
+ break;
263
+ }
264
+ case 'media': {
265
+ blur = BlurLevel.Media;
266
+ break;
267
+ }
268
+ }
269
+
270
+ switch (def.severity) {
271
+ case 'alert': {
272
+ severity = SeverityLevel.Alert;
273
+ break;
274
+ }
275
+ case 'inform': {
276
+ severity = SeverityLevel.Inform;
277
+ break;
278
+ }
279
+ }
280
+
281
+ switch (def.defaultSetting) {
282
+ case 'hide': {
283
+ defaultPref = LabelPreference.Hide;
284
+ break;
285
+ }
286
+ case 'ignore': {
287
+ defaultPref = LabelPreference.Ignore;
288
+ break;
289
+ }
290
+ }
291
+
292
+ if (def.adultOnly) {
293
+ flags |= LabelFlags.AdultOnly;
294
+ }
295
+
296
+ return {
297
+ identifier: def.identifier,
298
+ blur: blur,
299
+ severity: severity,
300
+ flags: flags,
301
+ behavior: getLabelBehaviorMatrix(blur, severity, flags),
302
+ defaultPref: defaultPref,
303
+ locales: def.locales.map((locale) => ({
304
+ name: locale.name,
305
+ lang: locale.lang,
306
+ description: locale.description,
307
+ })),
308
+ };
309
+ };
310
+
311
+ export const interpretLabelerDefinition = (
312
+ labeler: AppBskyLabelerDefs.LabelerViewDetailed,
313
+ ): InterpretedLabelMapping => {
314
+ const { labelValues, labelValueDefinitions = [] } = labeler.policies;
315
+
316
+ const mapping: InterpretedLabelMapping = {};
317
+
318
+ for (const definition of labelValueDefinitions) {
319
+ const identifier = definition.identifier;
320
+
321
+ // skip if it's not a valid custom label identifier
322
+ if (!isCustomLabelValue(identifier)) {
323
+ continue;
324
+ }
325
+
326
+ // skip if it's not listed in `labelValues`
327
+ //
328
+ // `labelValues` controls how Bluesky app should display the label
329
+ // preferences, and by omitting it, users can't configure the label.
330
+ //
331
+ // in my understanding, this should be invalid. labels should always be
332
+ // configurable. the only exception being enforced regional labelers.
333
+ if (!labelValues.includes(identifier)) {
334
+ continue;
335
+ }
336
+
337
+ mapping[identifier] = interpretLabelValueDefinition(definition);
338
+ }
339
+
340
+ return mapping;
341
+ };
342
+
343
+ export const interpretLabelerDefinitions = (
344
+ labelers: AppBskyLabelerDefs.LabelerViewDetailed[],
345
+ ): Record<At.Did, InterpretedLabelMapping> => {
346
+ const labelDefs: Record<At.Did, InterpretedLabelMapping> = {};
347
+
348
+ for (const labeler of labelers) {
349
+ labelDefs[labeler.creator.did] = interpretLabelerDefinition(labeler);
350
+ }
351
+
352
+ return labelDefs;
353
+ };
@@ -0,0 +1,22 @@
1
+ import { LabelTarget } from '../behaviors.js';
2
+ import {
3
+ considerLabels,
4
+ createModerationDecision,
5
+ mergeModerationDecisions,
6
+ type ModerationDecision,
7
+ } from '../decision.js';
8
+ import type { FeedGeneratorSubject, ModerationOptions } from '../types.js';
9
+
10
+ import { moderateProfile } from './profile.js';
11
+
12
+ export const moderateFeedGenerator = (
13
+ subject: FeedGeneratorSubject,
14
+ opts: ModerationOptions,
15
+ ): ModerationDecision => {
16
+ const creator = subject.creator;
17
+
18
+ const decision = createModerationDecision(creator.did, opts);
19
+ considerLabels(decision, LabelTarget.Content, subject.labels, opts);
20
+
21
+ return mergeModerationDecisions(decision, moderateProfile(creator, opts));
22
+ };
@@ -0,0 +1,41 @@
1
+ import type { At } from '@atcute/client/lexicons';
2
+ import { LabelTarget } from '../behaviors.js';
3
+ import {
4
+ considerLabels,
5
+ createModerationDecision,
6
+ mergeModerationDecisions,
7
+ type ModerationDecision,
8
+ } from '../decision.js';
9
+ import type { ModerationOptions, ListSubject } from '../types.js';
10
+
11
+ import { moderateProfile } from './profile.js';
12
+
13
+ const ATURI_RE = /^at:\/\/([a-zA-Z0-9._:%-]+)\//;
14
+
15
+ export const moderateList = (subject: ListSubject, opts: ModerationOptions): ModerationDecision => {
16
+ if ('creator' in subject) {
17
+ const creator = subject.creator;
18
+
19
+ const decision = createModerationDecision(creator.did, opts);
20
+ considerLabels(decision, LabelTarget.Content, subject.labels, opts);
21
+
22
+ return mergeModerationDecisions(decision, moderateProfile(creator, opts));
23
+ } else {
24
+ let creatorDid: At.Did;
25
+
26
+ // TODO: can we have @atcute/syntax yet
27
+ {
28
+ const match = ATURI_RE.exec(subject.uri);
29
+ if (!match) {
30
+ throw new Error(`can't parse at-uri from user list`);
31
+ }
32
+
33
+ creatorDid = match[1] as At.Did;
34
+ }
35
+
36
+ const decision = createModerationDecision(creatorDid, opts);
37
+ considerLabels(decision, LabelTarget.Content, subject.labels, opts);
38
+
39
+ return decision;
40
+ }
41
+ };
@@ -0,0 +1,22 @@
1
+ import { LabelTarget } from '../behaviors.js';
2
+ import {
3
+ considerLabels,
4
+ createModerationDecision,
5
+ mergeModerationDecisions,
6
+ type ModerationDecision,
7
+ } from '../decision.js';
8
+ import type { ModerationOptions, NotificationSubject } from '../types.js';
9
+
10
+ import { moderateProfile } from './profile.js';
11
+
12
+ export const moderateNotification = (
13
+ subject: NotificationSubject,
14
+ opts: ModerationOptions,
15
+ ): ModerationDecision => {
16
+ const author = subject.author;
17
+
18
+ const decision = createModerationDecision(author.did, opts);
19
+ considerLabels(decision, LabelTarget.Content, subject.labels, opts);
20
+
21
+ return mergeModerationDecisions(decision, moderateProfile(author, opts));
22
+ };