A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules
1import { agent, isLoggedIn } from "./agent.js";
2import { MOD_DID } from "./config.js";
3import { limit } from "./limits.js";
4import { logger } from "./logger.js";
5import {
6 labelsAppliedCounter,
7 labelsCachedCounter,
8 unlabelsRemovedCounter,
9} from "./metrics.js";
10import {
11 deleteAccountLabelClaim,
12 tryClaimAccountComment,
13 tryClaimAccountLabel,
14} from "./redis.js";
15
16const doesLabelExist = (
17 labels: { val: string }[] | undefined,
18 labelVal: string,
19): boolean => {
20 if (!labels) {
21 return false;
22 }
23 return labels.some((label) => label.val === labelVal);
24};
25
26export const createAccountLabel = async (
27 did: string,
28 label: string,
29 comment: string,
30) => {
31 await isLoggedIn;
32
33 const claimed = await tryClaimAccountLabel(did, label);
34 if (!claimed) {
35 logger.debug(
36 { process: "MODERATION", did, label },
37 "Account label already claimed in Redis, skipping",
38 );
39 labelsCachedCounter.inc({
40 label_type: label,
41 target_type: "account",
42 reason: "redis_cache",
43 });
44 return;
45 }
46
47 const hasLabel = await checkAccountLabels(did, label);
48 if (hasLabel) {
49 logger.debug(
50 { process: "MODERATION", did, label },
51 "Account already has label, skipping",
52 );
53 labelsCachedCounter.inc({
54 label_type: label,
55 target_type: "account",
56 reason: "existing_label",
57 });
58 return;
59 }
60
61 logger.info({ process: "MODERATION", did, label }, "Labeling account");
62 labelsAppliedCounter.inc({ label_type: label, target_type: "account" });
63
64 await limit(async () => {
65 try {
66 await agent.tools.ozone.moderation.emitEvent(
67 {
68 event: {
69 $type: "tools.ozone.moderation.defs#modEventLabel",
70 comment,
71 createLabelVals: [label],
72 negateLabelVals: [],
73 },
74 // specify the labeled post by strongRef
75 subject: {
76 $type: "com.atproto.admin.defs#repoRef",
77 did,
78 },
79 // put in the rest of the metadata
80 createdBy: agent.did ?? "",
81 createdAt: new Date().toISOString(),
82 modTool: {
83 name: "skywatch/skywatch-automod",
84 meta: {
85 time: new Date().toISOString(),
86 externalUrl: `https://pdsls.dev/at://${did}`,
87 },
88 },
89 },
90 {
91 encoding: "application/json",
92 headers: {
93 "atproto-proxy": `${MOD_DID}#atproto_labeler`,
94 "atproto-accept-labelers":
95 "did:plc:ar7c4by46qjdydhdevvrndac;redact",
96 },
97 },
98 );
99 } catch (e) {
100 logger.error(
101 { process: "MODERATION", error: e },
102 "Failed to create account label",
103 );
104 throw e;
105 }
106 });
107};
108
109export const createAccountComment = async (
110 did: string,
111 comment: string,
112 atURI: string,
113) => {
114 await isLoggedIn;
115
116 const claimed = await tryClaimAccountComment(did, atURI);
117 if (!claimed) {
118 logger.debug(
119 { process: "MODERATION", did, atURI },
120 "Account comment already claimed in Redis, skipping",
121 );
122 return;
123 }
124
125 logger.info({ process: "MODERATION", did, atURI }, "Commenting on account");
126
127 await limit(async () => {
128 try {
129 await agent.tools.ozone.moderation.emitEvent(
130 {
131 event: {
132 $type: "tools.ozone.moderation.defs#modEventComment",
133 comment,
134 },
135 // specify the labeled post by strongRef
136 subject: {
137 $type: "com.atproto.admin.defs#repoRef",
138 did,
139 },
140 // put in the rest of the metadata
141 createdBy: agent.did ?? "",
142 createdAt: new Date().toISOString(),
143 modTool: {
144 name: "skywatch/skywatch-automod",
145 meta: {
146 time: new Date().toISOString(),
147 externalUrl: `https://pdsls.dev/at://${did}`,
148 },
149 },
150 },
151 {
152 encoding: "application/json",
153 headers: {
154 "atproto-proxy": `${MOD_DID}#atproto_labeler`,
155 "atproto-accept-labelers":
156 "did:plc:ar7c4by46qjdydhdevvrndac;redact",
157 },
158 },
159 );
160 } catch (e) {
161 logger.error(
162 { process: "MODERATION", error: e },
163 "Failed to create account comment",
164 );
165 throw e;
166 }
167 });
168};
169
170export const createAccountReport = async (did: string, comment: string) => {
171 await isLoggedIn;
172 await limit(async () => {
173 try {
174 await agent.tools.ozone.moderation.emitEvent(
175 {
176 event: {
177 $type: "tools.ozone.moderation.defs#modEventReport",
178 comment,
179 reportType: "com.atproto.moderation.defs#reasonOther",
180 },
181 // specify the labeled post by strongRef
182 subject: {
183 $type: "com.atproto.admin.defs#repoRef",
184 did,
185 },
186 // put in the rest of the metadata
187 createdBy: agent.did ?? "",
188 createdAt: new Date().toISOString(),
189 modTool: {
190 name: "skywatch/skywatch-automod",
191 meta: {
192 time: new Date().toISOString(),
193 externalUrl: `https://pdsls.dev/at://${did}`,
194 },
195 },
196 },
197 {
198 encoding: "application/json",
199 headers: {
200 "atproto-proxy": `${MOD_DID}#atproto_labeler`,
201 "atproto-accept-labelers":
202 "did:plc:ar7c4by46qjdydhdevvrndac;redact",
203 },
204 },
205 );
206 } catch (e) {
207 logger.error(
208 { process: "MODERATION", error: e },
209 "Failed to create account report",
210 );
211 throw e;
212 }
213 });
214};
215
216export const negateAccountLabel = async (
217 did: string,
218 label: string,
219 comment: string,
220) => {
221 await isLoggedIn;
222
223 const hasLabel = await checkAccountLabels(did, label);
224 if (!hasLabel) {
225 logger.debug(
226 { process: "MODERATION", did, label },
227 "Account does not have label, skipping",
228 );
229 return;
230 }
231
232 logger.info({ process: "MODERATION", did, label }, "Unlabeling account");
233 unlabelsRemovedCounter.inc({ label_type: label, target_type: "account" });
234
235 await limit(async () => {
236 try {
237 await agent.tools.ozone.moderation.emitEvent(
238 {
239 event: {
240 $type: "tools.ozone.moderation.defs#modEventLabel",
241 comment,
242 createLabelVals: [],
243 negateLabelVals: [label],
244 },
245 // specify the labeled post by strongRef
246 subject: {
247 $type: "com.atproto.admin.defs#repoRef",
248 did,
249 },
250 // put in the rest of the metadata
251 createdBy: agent.did ?? "",
252 createdAt: new Date().toISOString(),
253 modTool: {
254 name: "skywatch/skywatch-automod",
255 meta: {
256 time: new Date().toISOString(),
257 externalUrl: `https://pdsls.dev/at://${did}`,
258 },
259 },
260 },
261 {
262 encoding: "application/json",
263 headers: {
264 "atproto-proxy": `${MOD_DID}#atproto_labeler`,
265 "atproto-accept-labelers":
266 "did:plc:ar7c4by46qjdydhdevvrndac;redact",
267 },
268 },
269 );
270 await deleteAccountLabelClaim(did, label);
271 } catch (e) {
272 logger.error(
273 { process: "MODERATION", error: e },
274 "Failed to negate account label",
275 );
276 throw e;
277 }
278 });
279};
280
281export const checkAccountLabels = async (
282 did: string,
283 label: string,
284): Promise<boolean> => {
285 await isLoggedIn;
286 return await limit(async () => {
287 try {
288 const response = await agent.tools.ozone.moderation.getRepo(
289 { did },
290 {
291 headers: {
292 "atproto-proxy": `${MOD_DID}#atproto_labeler`,
293 "atproto-accept-labelers":
294 "did:plc:ar7c4by46qjdydhdevvrndac;redact",
295 },
296 },
297 );
298
299 return doesLabelExist(response.data.labels, label);
300 } catch (e) {
301 logger.error(
302 { process: "MODERATION", did, error: e },
303 "Failed to check account labels",
304 );
305 return false;
306 }
307 });
308};
309
310export const getAllAccountLabels = async (did: string): Promise<string[]> => {
311 await isLoggedIn;
312 return await limit(async () => {
313 try {
314 const response = await agent.tools.ozone.moderation.getRepo(
315 { did },
316 {
317 headers: {
318 "atproto-proxy": `${MOD_DID}#atproto_labeler`,
319 "atproto-accept-labelers":
320 "did:plc:ar7c4by46qjdydhdevvrndac;redact",
321 },
322 },
323 );
324
325 return (response.data.labels ?? []).map((label) => label.val);
326 } catch (e) {
327 logger.error(
328 { process: "MODERATION", did, error: e },
329 "Failed to get account labels",
330 );
331 return [];
332 }
333 });
334};