@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,316 @@
1
+ import { unwrapEmbed, unwrapRecordEmbed } from '@atcute/bluesky';
2
+ import type {
3
+ AppBskyActorDefs,
4
+ AppBskyFeedDefs,
5
+ AppBskyFeedPost,
6
+ AppBskyGraphStarterpack,
7
+ At,
8
+ } from '@atcute/client/lexicons';
9
+
10
+ import { LabelTarget } from '../behaviors.js';
11
+ import {
12
+ considerBlockedBy,
13
+ considerBlocking,
14
+ considerHidden,
15
+ considerKeywordMute,
16
+ considerLabels,
17
+ createModerationDecision,
18
+ downgradeDecision,
19
+ mergeModerationDecisions,
20
+ type ModerationDecision,
21
+ } from '../decision.js';
22
+ import type { ModerationOptions, PostSubject } from '../types.js';
23
+
24
+ import { matchesKeywordFilters } from '../internal/keyword-filter.js';
25
+ import type { KeywordFilter } from '../keyword-filter.js';
26
+
27
+ import { moderateProfile } from './profile.js';
28
+
29
+ export const moderatePost = (subject: PostSubject, opts: ModerationOptions) => {
30
+ return mergeModerationDecisions(
31
+ decideSubject(subject, opts),
32
+ moderateProfile(subject.author, opts),
33
+ downgradeDecision(decideEmbed(subject.embed, opts)),
34
+ );
35
+ };
36
+
37
+ const decideSubject = (subject: PostSubject, opts: ModerationOptions): ModerationDecision => {
38
+ const author = subject.author;
39
+
40
+ const decision = createModerationDecision(author.did, opts);
41
+ considerLabels(decision, LabelTarget.Content, subject.labels, opts);
42
+
43
+ if (checkHiddenPost(subject, opts.prefs.hiddenPosts)) {
44
+ considerHidden(decision);
45
+ }
46
+
47
+ if (!decision.isMe && opts.prefs.keywordFilters?.length) {
48
+ const match = checkKeywordFilters(subject, opts.prefs.keywordFilters);
49
+ if (match !== null) {
50
+ considerKeywordMute(decision, match);
51
+ }
52
+ }
53
+
54
+ return decision;
55
+ };
56
+
57
+ const decideEmbed = (
58
+ embed: AppBskyFeedDefs.PostView['embed'],
59
+ opts: ModerationOptions,
60
+ ): ModerationDecision | undefined => {
61
+ const link = unwrapRecordEmbed(embed);
62
+
63
+ if (link) {
64
+ switch (link.$type) {
65
+ case 'app.bsky.embed.record#viewRecord': {
66
+ const author = link.author;
67
+
68
+ const decision = createModerationDecision(author.did, opts);
69
+ considerLabels(decision, LabelTarget.Content, link.labels, opts);
70
+
71
+ return mergeModerationDecisions(decision, moderateProfile(author, opts));
72
+ }
73
+ case 'app.bsky.embed.record#viewBlocked': {
74
+ const author = link.author;
75
+ const viewer = author.viewer;
76
+
77
+ const decision = createModerationDecision(author.did, opts);
78
+
79
+ if (viewer?.blocking) {
80
+ considerBlocking(decision, viewer.blockingByList ?? null);
81
+ }
82
+
83
+ if (viewer?.blockedBy) {
84
+ considerBlockedBy(decision);
85
+ }
86
+
87
+ return decision;
88
+ }
89
+ }
90
+ }
91
+ };
92
+
93
+ const checkHiddenPost = (
94
+ subject: PostSubject,
95
+ hiddenPosts: At.CanonicalResourceUri[] | undefined,
96
+ ): boolean => {
97
+ if (!hiddenPosts?.length) {
98
+ return false;
99
+ }
100
+
101
+ if (hiddenPosts.includes(subject.uri as At.CanonicalResourceUri)) {
102
+ return true;
103
+ }
104
+
105
+ const record = unwrapRecordEmbed(subject.embed);
106
+ if (record) {
107
+ switch (record.$type) {
108
+ case 'app.bsky.embed.record#viewBlocked':
109
+ case 'app.bsky.embed.record#viewDetached':
110
+ case 'app.bsky.embed.record#viewNotFound':
111
+ case 'app.bsky.embed.record#viewRecord': {
112
+ return hiddenPosts.includes(subject.uri as At.CanonicalResourceUri);
113
+ }
114
+ }
115
+ }
116
+
117
+ return false;
118
+ };
119
+
120
+ const checkKeywordFilters = (
121
+ subject: PostSubject,
122
+ filters: KeywordFilter[] | undefined,
123
+ ): KeywordFilter | null => {
124
+ if (!filters?.length) {
125
+ return null;
126
+ }
127
+
128
+ let match: KeywordFilter | null | undefined;
129
+
130
+ const author = subject.author;
131
+
132
+ {
133
+ const record = subject.record as AppBskyFeedPost.Record;
134
+
135
+ const tags: string[] = [
136
+ ...(record.tags ?? []),
137
+ ...(record.facets ?? []).flatMap((facet) => {
138
+ return facet.features
139
+ .filter((feature) => feature.$type === 'app.bsky.richtext.facet#tag')
140
+ .map((feature) => feature.tag);
141
+ }),
142
+ ];
143
+
144
+ if (
145
+ (match = matchesKeywordFilters({
146
+ filters: filters,
147
+ text: record.text,
148
+ tags: tags,
149
+ actor: author,
150
+ }))
151
+ ) {
152
+ return match;
153
+ }
154
+ }
155
+
156
+ {
157
+ const embed = subject.embed;
158
+
159
+ if ((match = checkEmbedKeywordFilters(filters, embed, author))) {
160
+ return match;
161
+ }
162
+ }
163
+
164
+ return null;
165
+ };
166
+
167
+ const checkEmbedKeywordFilters = (
168
+ filters: KeywordFilter[],
169
+ embed: AppBskyFeedDefs.PostView['embed'],
170
+ author: AppBskyActorDefs.ProfileViewBasic,
171
+ ): KeywordFilter | null => {
172
+ let match: KeywordFilter | null | undefined;
173
+
174
+ const { media, record: link } = unwrapEmbed(embed);
175
+
176
+ if (media) {
177
+ switch (media.$type) {
178
+ case 'app.bsky.embed.external#view': {
179
+ const external = media.external;
180
+
181
+ if (
182
+ (match = matchesKeywordFilters({
183
+ filters: filters,
184
+ text: `${external.title} ${external.description}`,
185
+ actor: author,
186
+ }))
187
+ ) {
188
+ return match;
189
+ }
190
+
191
+ break;
192
+ }
193
+ case 'app.bsky.embed.images#view': {
194
+ const images = media.images;
195
+
196
+ for (let i = 0, il = images.length; i < il; i++) {
197
+ const image = images[i];
198
+ if (!image.alt) {
199
+ continue;
200
+ }
201
+
202
+ if (
203
+ (match = matchesKeywordFilters({
204
+ filters: filters,
205
+ text: image.alt,
206
+ actor: author,
207
+ }))
208
+ ) {
209
+ return match;
210
+ }
211
+ }
212
+
213
+ break;
214
+ }
215
+ case 'app.bsky.embed.video#view': {
216
+ if (!media.alt) {
217
+ break;
218
+ }
219
+
220
+ if (
221
+ (match = matchesKeywordFilters({
222
+ filters: filters,
223
+ text: media.alt,
224
+ actor: author,
225
+ }))
226
+ ) {
227
+ return match;
228
+ }
229
+ }
230
+ }
231
+ }
232
+
233
+ if (link) {
234
+ switch (link.$type) {
235
+ case 'app.bsky.embed.record#viewRecord': {
236
+ {
237
+ const author = link.author;
238
+ const record = link.value as AppBskyFeedPost.Record;
239
+
240
+ const tags: string[] = [
241
+ ...(record.tags ?? []),
242
+ ...(record.facets ?? []).flatMap((facet) => {
243
+ return facet.features
244
+ .filter((feature) => feature.$type === 'app.bsky.richtext.facet#tag')
245
+ .map((feature) => feature.tag);
246
+ }),
247
+ ];
248
+
249
+ if (
250
+ (match = matchesKeywordFilters({
251
+ filters: filters,
252
+ text: record.text,
253
+ tags: tags,
254
+ actor: author,
255
+ }))
256
+ ) {
257
+ return match;
258
+ }
259
+ }
260
+
261
+ {
262
+ const embed = link.embeds?.[0];
263
+
264
+ if ((match = checkEmbedKeywordFilters(filters, embed, author))) {
265
+ return match;
266
+ }
267
+ }
268
+
269
+ break;
270
+ }
271
+ case 'app.bsky.feed.defs#generatorView': {
272
+ if (
273
+ (match = matchesKeywordFilters({
274
+ filters: filters,
275
+ text: `${link.displayName} ${link.description ?? ''}`,
276
+ actor: link.creator,
277
+ }))
278
+ ) {
279
+ return match;
280
+ }
281
+
282
+ break;
283
+ }
284
+ case 'app.bsky.graph.defs#listView': {
285
+ if (
286
+ (match = matchesKeywordFilters({
287
+ filters: filters,
288
+ text: `${link.name} ${link.description ?? ''}`,
289
+ actor: link.creator,
290
+ }))
291
+ ) {
292
+ return match;
293
+ }
294
+
295
+ break;
296
+ }
297
+ case 'app.bsky.graph.defs#starterPackViewBasic': {
298
+ const record = link.record as AppBskyGraphStarterpack.Record;
299
+
300
+ if (
301
+ (match = matchesKeywordFilters({
302
+ filters: filters,
303
+ text: `${record.name} ${record.description ?? ''}`,
304
+ actor: link.creator,
305
+ }))
306
+ ) {
307
+ return match;
308
+ }
309
+
310
+ break;
311
+ }
312
+ }
313
+ }
314
+
315
+ return null;
316
+ };
@@ -0,0 +1,42 @@
1
+ import { LabelTarget } from '../behaviors.js';
2
+ import {
3
+ considerBlockedBy,
4
+ considerBlocking,
5
+ considerLabel,
6
+ considerPermanentMute,
7
+ createModerationDecision,
8
+ type ModerationDecision,
9
+ } from '../decision.js';
10
+ import type { ModerationOptions, ProfileSubject } from '../types.js';
11
+
12
+ export const moderateProfile = (subject: ProfileSubject, opts: ModerationOptions): ModerationDecision => {
13
+ const decision = createModerationDecision(subject.did, opts);
14
+
15
+ const viewer = subject.viewer;
16
+ if (viewer) {
17
+ if (viewer.muted) {
18
+ considerPermanentMute(decision, viewer.mutedByList ?? null);
19
+ }
20
+
21
+ if (viewer.blocking) {
22
+ considerBlocking(decision, viewer.blockingByList ?? null);
23
+ }
24
+
25
+ if (viewer.blockedBy) {
26
+ considerBlockedBy(decision);
27
+ }
28
+ }
29
+
30
+ if (subject.labels?.length) {
31
+ for (const label of subject.labels) {
32
+ const target =
33
+ !label.uri.endsWith('/app.bsky.actor.profile/self') || label.val === '!no-unauthenticated'
34
+ ? LabelTarget.Account
35
+ : LabelTarget.Profile;
36
+
37
+ considerLabel(decision, target, label, opts);
38
+ }
39
+ }
40
+
41
+ return decision;
42
+ };
package/lib/types.ts ADDED
@@ -0,0 +1,61 @@
1
+ import type {
2
+ AppBskyActorDefs,
3
+ AppBskyFeedDefs,
4
+ AppBskyGraphDefs,
5
+ AppBskyNotificationListNotifications,
6
+ At,
7
+ ChatBskyActorDefs,
8
+ ComAtprotoLabelDefs,
9
+ } from '@atcute/client/lexicons';
10
+
11
+ import type { KeywordFilter } from './keyword-filter.js';
12
+ import type { InterpretedLabelMapping, LabelPreference } from './label.js';
13
+
14
+ export type Label = ComAtprotoLabelDefs.Label;
15
+
16
+ export interface LabelerPreference {
17
+ /** preferences for labels issued by this labeler */
18
+ labelPrefs: Record<string, LabelPreference | undefined>;
19
+ }
20
+
21
+ export interface ModerationPreferences {
22
+ /** whether adult content is allowed to be shown */
23
+ adultContentEnabled?: boolean;
24
+
25
+ /** preferences for global-defined labels */
26
+ globalLabelPrefs?: Record<string, LabelPreference | undefined>;
27
+ /** preferences for labelers */
28
+ prefsByLabelers?: { [D in At.Did]?: LabelerPreference };
29
+
30
+ /** list of hidden posts */
31
+ hiddenPosts?: At.CanonicalResourceUri[];
32
+ /** list of temporarily muted users */
33
+ temporaryMutes?: At.Did[];
34
+
35
+ /** list of keyword filters */
36
+ keywordFilters?: KeywordFilter[];
37
+ }
38
+
39
+ export interface ModerationOptions {
40
+ /** DID of the viewer */
41
+ viewerDid: At.Did | undefined;
42
+ /** moderation preferences */
43
+ prefs: ModerationPreferences;
44
+
45
+ /** interpreted label definitions from labelers */
46
+ labelDefs?: { [D in At.Did]?: InterpretedLabelMapping };
47
+ }
48
+
49
+ export type FeedGeneratorSubject = AppBskyFeedDefs.GeneratorView;
50
+
51
+ export type ListSubject = AppBskyGraphDefs.ListView | AppBskyGraphDefs.ListViewBasic;
52
+
53
+ export type NotificationSubject = AppBskyNotificationListNotifications.Notification;
54
+
55
+ export type PostSubject = AppBskyFeedDefs.PostView;
56
+
57
+ export type ProfileSubject =
58
+ | AppBskyActorDefs.ProfileViewBasic
59
+ | AppBskyActorDefs.ProfileView
60
+ | AppBskyActorDefs.ProfileViewDetailed
61
+ | ChatBskyActorDefs.ProfileViewBasic;
package/lib/ui.ts ADDED
@@ -0,0 +1,183 @@
1
+ import {
2
+ BLOCK_BEHAVIOR,
3
+ DisplayContext,
4
+ HIDE_BEHAVIOR,
5
+ KEYWORD_MUTE_BEHAVIOR,
6
+ LabelTarget,
7
+ ModerationAction,
8
+ MUTE_BEHAVIOR,
9
+ } from './behaviors.js';
10
+ import { ModerationCauseType, type ModerationCause, type ModerationDecision } from './decision.js';
11
+ import { LabelPreference } from './label.js';
12
+
13
+ export interface DisplayRestrictions {
14
+ noOverride: boolean;
15
+ filters: ModerationCause[];
16
+ blurs: ModerationCause[];
17
+ alerts: ModerationCause[];
18
+ informs: ModerationCause[];
19
+ }
20
+
21
+ export const getDisplayRestrictions = (
22
+ decision: ModerationDecision,
23
+ context: DisplayContext,
24
+ ): DisplayRestrictions => {
25
+ const filters: ModerationCause[] = [];
26
+ const blurs: ModerationCause[] = [];
27
+ const alerts: ModerationCause[] = [];
28
+ const informs: ModerationCause[] = [];
29
+
30
+ let noOverride: boolean = false;
31
+
32
+ for (const cause of decision.causes) {
33
+ switch (cause.type) {
34
+ case ModerationCauseType.Label: {
35
+ if (cause.pref === LabelPreference.Hide && !decision.isMe) {
36
+ if (context === DisplayContext.ProfileList && cause.target == LabelTarget.Account) {
37
+ filters.push(cause);
38
+ } else if (
39
+ context === DisplayContext.ContentList &&
40
+ (cause.target === LabelTarget.Account || cause.target === LabelTarget.Content)
41
+ ) {
42
+ filters.push(cause);
43
+ }
44
+ }
45
+
46
+ if (!cause.downgraded) {
47
+ switch (cause.behavior[context]) {
48
+ case ModerationAction.Blur: {
49
+ noOverride ||= cause.noOverride && !decision.isMe;
50
+ blurs.push(cause);
51
+ break;
52
+ }
53
+ case ModerationAction.Alert: {
54
+ alerts.push(cause);
55
+ break;
56
+ }
57
+ case ModerationAction.Inform: {
58
+ informs.push(cause);
59
+ break;
60
+ }
61
+ }
62
+ }
63
+
64
+ break;
65
+ }
66
+
67
+ case ModerationCauseType.MutedPermanent:
68
+ case ModerationCauseType.MutedTemporary: {
69
+ if (context === DisplayContext.ProfileList || context === DisplayContext.ContentList) {
70
+ filters.push(cause);
71
+ }
72
+
73
+ if (!cause.downgraded) {
74
+ switch (MUTE_BEHAVIOR[context]) {
75
+ case ModerationAction.Blur: {
76
+ blurs.push(cause);
77
+ break;
78
+ }
79
+ case ModerationAction.Alert: {
80
+ alerts.push(cause);
81
+ break;
82
+ }
83
+ case ModerationAction.Inform: {
84
+ informs.push(cause);
85
+ break;
86
+ }
87
+ }
88
+ }
89
+
90
+ break;
91
+ }
92
+
93
+ case ModerationCauseType.MutedKeyword: {
94
+ if (context === DisplayContext.ContentList) {
95
+ filters.push(cause);
96
+ }
97
+
98
+ if (!cause.downgraded) {
99
+ switch (KEYWORD_MUTE_BEHAVIOR[context]) {
100
+ case ModerationAction.Blur: {
101
+ blurs.push(cause);
102
+ break;
103
+ }
104
+ case ModerationAction.Alert: {
105
+ alerts.push(cause);
106
+ break;
107
+ }
108
+ case ModerationAction.Inform: {
109
+ informs.push(cause);
110
+ break;
111
+ }
112
+ }
113
+ }
114
+
115
+ break;
116
+ }
117
+
118
+ case ModerationCauseType.Hidden: {
119
+ if (context === DisplayContext.ProfileList || context === DisplayContext.ContentList) {
120
+ filters.push(cause);
121
+ }
122
+
123
+ if (!cause.downgraded) {
124
+ switch (HIDE_BEHAVIOR[context]) {
125
+ case ModerationAction.Blur: {
126
+ blurs.push(cause);
127
+ break;
128
+ }
129
+ case ModerationAction.Alert: {
130
+ alerts.push(cause);
131
+ break;
132
+ }
133
+ case ModerationAction.Inform: {
134
+ informs.push(cause);
135
+ break;
136
+ }
137
+ }
138
+ }
139
+
140
+ break;
141
+ }
142
+
143
+ case ModerationCauseType.BlockedBy:
144
+ case ModerationCauseType.Blocking: {
145
+ if (context === DisplayContext.ProfileList || context === DisplayContext.ContentList) {
146
+ filters.push(cause);
147
+ }
148
+
149
+ if (!cause.downgraded) {
150
+ switch (BLOCK_BEHAVIOR[context]) {
151
+ case ModerationAction.Blur: {
152
+ noOverride = true;
153
+ blurs.push(cause);
154
+ break;
155
+ }
156
+ case ModerationAction.Alert: {
157
+ alerts.push(cause);
158
+ break;
159
+ }
160
+ case ModerationAction.Inform: {
161
+ informs.push(cause);
162
+ break;
163
+ }
164
+ }
165
+ }
166
+
167
+ break;
168
+ }
169
+ }
170
+ }
171
+
172
+ return {
173
+ noOverride,
174
+ filters: filters.sort(sortByPriority),
175
+ blurs: blurs.sort(sortByPriority),
176
+ alerts: alerts.sort(sortByPriority),
177
+ informs: informs.sort(sortByPriority),
178
+ };
179
+ };
180
+
181
+ const sortByPriority = (a: ModerationCause, b: ModerationCause) => {
182
+ return a.priority - b.priority;
183
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "type": "module",
3
+ "name": "@atcute/bluesky-moderation",
4
+ "version": "1.0.1",
5
+ "description": "interprets Bluesky's content moderation labels",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "url": "https://github.com/mary-ext/atcute",
9
+ "directory": "packages/bluesky/moderation"
10
+ },
11
+ "files": [
12
+ "dist/",
13
+ "lib/",
14
+ "!lib/**/*.bench.ts",
15
+ "!lib/**/*.test.ts"
16
+ ],
17
+ "exports": {
18
+ ".": "./dist/index.js"
19
+ },
20
+ "sideEffects": false,
21
+ "peerDependencies": {
22
+ "@atcute/bluesky": "^2.0.0",
23
+ "@atcute/client": "^3.0.0"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "^1.2.1",
27
+ "vitest": "^3.0.4",
28
+ "@atcute/bluesky": "^2.0.3",
29
+ "@atcute/client": "^3.0.1"
30
+ },
31
+ "scripts": {
32
+ "build": "tsc --project tsconfig.build.json",
33
+ "test": "vitest",
34
+ "prepublish": "rm -rf dist; pnpm run build"
35
+ }
36
+ }