A tool for parsing traffic on the jetstream and applying a moderation workstream based on regexp based rules

1. src/rules/account/age.ts:8-22 - renamed ReplyContext → InteractionContext, added quotedDid and quotedPostURI fields for quote posts, actorDid as the common actor field 2. src/rules/account/age.ts:123-149 - updated matching logic to check both replies (replyToDid/replyToPostURI) AND quotes (quotedDid/quotedPostURI) 3. src/rules/account/ageConstants.ts:3-12 - updated documentation to reflect reply/quote monitoring 4. added 4 new tests covering: - labeling when quoting monitored DID - labeling when quoting monitored post URI - not labeling when quoting different DID - matching either reply OR quote to monitored target

Skywatch d963ccc6 89fd7aae

Changed files
+213 -37
src
rules
+55 -26
src/rules/account/age.ts
··· 5 5 import { PLC_URL } from "../../config.js"; 6 6 import { GLOBAL_ALLOW } from "../../constants.js"; 7 7 8 - interface ReplyContext { 9 - replyToDid: string; 10 - replyingDid: string; 11 - atURI: string; 12 - time: number; 8 + interface InteractionContext { 9 + // For replies 10 + replyToDid?: string; 11 + replyingDid?: string; 13 12 replyToPostURI?: string; // The URI of the post being replied to (optional) 13 + 14 + // For quote posts 15 + quotedDid?: string; // DID of the account whose post is being quoted 16 + quotedPostURI?: string; // URI of the post being quoted 17 + 18 + // Common fields (required) 19 + actorDid: string; // The DID performing the action (replying or quoting) 20 + atURI: string; // URI of the reply or quote post 21 + time: number; 14 22 } 15 23 16 24 /** ··· 87 95 }; 88 96 89 97 /** 90 - * Checks if a reply meets age criteria and applies labels accordingly 98 + * Checks if a reply or quote post meets age criteria and applies labels accordingly 91 99 */ 92 - export const checkAccountAge = async (context: ReplyContext): Promise<void> => { 100 + export const checkAccountAge = async ( 101 + context: InteractionContext, 102 + ): Promise<void> => { 93 103 // Skip if no checks configured 94 104 if (ACCOUNT_AGE_CHECKS.length === 0) { 95 105 return; 96 106 } 97 107 98 108 // Skip if DID is globally allowlisted 99 - if (GLOBAL_ALLOW.includes(context.replyingDid)) { 109 + if (GLOBAL_ALLOW.includes(context.actorDid)) { 100 110 logger.debug( 101 111 { 102 112 process: "ACCOUNT_AGE", 103 - did: context.replyingDid, 113 + did: context.actorDid, 104 114 atURI: context.atURI, 105 115 }, 106 116 "Global allowlisted DID", ··· 110 120 111 121 // Check each configuration 112 122 for (const check of ACCOUNT_AGE_CHECKS) { 113 - // Check if this reply matches monitored DIDs or post URIs 114 - const matchesDID = 115 - check.monitoredDIDs && check.monitoredDIDs.includes(context.replyToDid); 116 - const matchesPostURI = 123 + // Check if this interaction matches monitored DIDs or post URIs 124 + // For replies: check replyToDid and replyToPostURI 125 + // For quotes: check quotedDid and quotedPostURI 126 + const matchesReplyDID = 127 + check.monitoredDIDs && 128 + context.replyToDid && 129 + check.monitoredDIDs.includes(context.replyToDid); 130 + const matchesReplyPostURI = 117 131 check.monitoredPostURIs && 118 132 context.replyToPostURI && 119 133 check.monitoredPostURIs.includes(context.replyToPostURI); 134 + const matchesQuoteDID = 135 + check.monitoredDIDs && 136 + context.quotedDid && 137 + check.monitoredDIDs.includes(context.quotedDid); 138 + const matchesQuotePostURI = 139 + check.monitoredPostURIs && 140 + context.quotedPostURI && 141 + check.monitoredPostURIs.includes(context.quotedPostURI); 120 142 121 - if (!matchesDID && !matchesPostURI) { 143 + if ( 144 + !matchesReplyDID && 145 + !matchesReplyPostURI && 146 + !matchesQuoteDID && 147 + !matchesQuotePostURI 148 + ) { 122 149 continue; 123 150 } 124 151 ··· 138 165 logger.debug( 139 166 { 140 167 process: "ACCOUNT_AGE", 141 - replyingDid: context.replyingDid, 168 + actorDid: context.actorDid, 142 169 replyToDid: context.replyToDid, 170 + quotedDid: context.quotedDid, 143 171 }, 144 - "Checking account age for reply to monitored DID", 172 + "Checking account age for interaction with monitored target", 145 173 ); 146 174 147 175 // Get account creation date 148 - const creationDate = await getAccountCreationDate(context.replyingDid); 176 + const creationDate = await getAccountCreationDate(context.actorDid); 149 177 if (!creationDate) { 150 178 logger.warn( 151 179 { 152 180 process: "ACCOUNT_AGE", 153 - replyingDid: context.replyingDid, 181 + actorDid: context.actorDid, 154 182 }, 155 183 "Could not determine creation date, skipping", 156 184 ); ··· 166 194 logger.debug( 167 195 { 168 196 process: "ACCOUNT_AGE", 169 - replyingDid: context.replyingDid, 197 + actorDid: context.actorDid, 170 198 creationDate: creationDate.toISOString(), 171 199 windowStart: windowStart.toISOString(), 172 200 windowEnd: windowEnd.toISOString(), ··· 178 206 if (creationDate >= windowStart && creationDate <= windowEnd) { 179 207 // Check if the label already exists to prevent duplicates 180 208 const labelExists = await checkAccountLabels( 181 - context.replyingDid, 209 + context.actorDid, 182 210 check.label, 183 211 ); 184 212 ··· 186 214 logger.debug( 187 215 { 188 216 process: "ACCOUNT_AGE", 189 - replyingDid: context.replyingDid, 217 + actorDid: context.actorDid, 190 218 label: check.label, 191 219 }, 192 220 "Label already exists, skipping duplicate", 193 221 ); 194 - // Only apply one label per reply 222 + // Only apply one label per interaction 195 223 return; 196 224 } 197 225 198 226 logger.info( 199 227 { 200 228 process: "ACCOUNT_AGE", 201 - replyingDid: context.replyingDid, 229 + actorDid: context.actorDid, 202 230 replyToDid: context.replyToDid, 231 + quotedDid: context.quotedDid, 203 232 atURI: context.atURI, 204 233 }, 205 234 "Labeling account created within the monitored date range", 206 235 ); 207 236 208 237 await createAccountLabel( 209 - context.replyingDid, 238 + context.actorDid, 210 239 check.label, 211 - `${context.time}: ${check.comment} - Account created within monitored range - Reply: ${context.atURI}`, 240 + `${context.time}: ${check.comment} - Account created within monitored range - Interaction: ${context.atURI}`, 212 241 ); 213 242 214 - // Only apply one label per reply 243 + // Only apply one label per interaction 215 244 return; 216 245 } 217 246 }
+5 -4
src/rules/account/ageConstants.ts
··· 3 3 /** 4 4 * Account age monitoring configurations 5 5 * 6 - * Each configuration monitors replies to specified DIDs and labels accounts 7 - * that are newer than the threshold relative to the anchor date. 6 + * Each configuration monitors replies and/or quote posts to specified DIDs or posts 7 + * and labels accounts that were created within a specific time window. 8 8 * 9 - * Example use case: 10 - * - Monitor replies to high-profile accounts during harassment campaigns 9 + * Example use cases: 10 + * - Monitor replies/quotes to high-profile accounts during harassment campaigns 11 11 * - Flag sock puppet accounts created to participate in coordinated harassment 12 + * - Detect brigading on specific controversial posts 12 13 */ 13 14 export const ACCOUNT_AGE_CHECKS: AccountAgeCheck[] = [ 14 15 // Example: Monitor replies to specific accounts
+153 -7
src/rules/account/tests/age.test.ts
··· 165 165 166 166 it("should skip if no checks configured", async () => { 167 167 await checkAccountAge({ 168 + actorDid: "did:plc:replier", 168 169 replyToDid: "did:plc:monitored", 169 - replyingDid: "did:plc:replier", 170 170 atURI: TEST_REPLY_URI, 171 171 time: TEST_TIME, 172 172 }); ··· 184 184 }); 185 185 186 186 await checkAccountAge({ 187 + actorDid: "did:plc:replier", 187 188 replyToDid: "did:plc:other", 188 189 replyingDid: "did:plc:replier", 189 190 atURI: TEST_REPLY_URI, ··· 212 213 }); 213 214 214 215 await checkAccountAge({ 215 - replyToDid: "did:plc:monitored", 216 + actorDid: "did:plc:inwindow", 217 + replyToDid: "did:plc:monitored", 216 218 replyingDid: "did:plc:inwindow", 217 219 atURI: TEST_REPLY_URI, 218 220 time: TEST_TIME, ··· 233 235 }); 234 236 235 237 await checkAccountAge({ 236 - replyToDid: "did:plc:monitored", 238 + actorDid: "did:plc:beforewindow", 239 + replyToDid: "did:plc:monitored", 237 240 replyingDid: "did:plc:beforewindow", 238 241 atURI: TEST_REPLY_URI, 239 242 time: TEST_TIME, ··· 250 253 }); 251 254 252 255 await checkAccountAge({ 253 - replyToDid: "did:plc:monitored", 256 + actorDid: "did:plc:afterwindow", 257 + replyToDid: "did:plc:monitored", 254 258 replyingDid: "did:plc:afterwindow", 255 259 atURI: TEST_REPLY_URI, 256 260 time: TEST_TIME, ··· 267 271 }); 268 272 269 273 await checkAccountAge({ 270 - replyToDid: "did:plc:monitored", 274 + actorDid: "did:plc:startofwindow", 275 + replyToDid: "did:plc:monitored", 271 276 replyingDid: "did:plc:startofwindow", 272 277 atURI: TEST_REPLY_URI, 273 278 time: TEST_TIME, ··· 284 289 }); 285 290 286 291 await checkAccountAge({ 287 - replyToDid: "did:plc:monitored", 292 + actorDid: "did:plc:endofwindow", 293 + replyToDid: "did:plc:monitored", 288 294 replyingDid: "did:plc:endofwindow", 289 295 atURI: TEST_REPLY_URI, 290 296 time: TEST_TIME, ··· 312 318 }); 313 319 314 320 await checkAccountAge({ 321 + actorDid: "did:plc:unknown", 315 322 replyToDid: "did:plc:monitored", 316 323 replyingDid: "did:plc:unknown", 317 324 atURI: TEST_REPLY_URI, ··· 354 361 }); 355 362 356 363 await checkAccountAge({ 364 + actorDid: "did:plc:newaccount", 357 365 replyToDid: "did:plc:monitored", 358 366 replyingDid: "did:plc:newaccount", 359 367 atURI: TEST_REPLY_URI, ··· 389 397 (checkAccountLabels as any).mockResolvedValueOnce(true); 390 398 391 399 await checkAccountAge({ 400 + actorDid: "did:plc:alreadylabeled", 392 401 replyToDid: "did:plc:monitored", 393 402 replyingDid: "did:plc:alreadylabeled", 394 403 atURI: TEST_REPLY_URI, ··· 403 412 expect(logger.debug).toHaveBeenCalledWith( 404 413 { 405 414 process: "ACCOUNT_AGE", 406 - replyingDid: "did:plc:alreadylabeled", 415 + actorDid: "did:plc:alreadylabeled", 407 416 label: "window-reply", 408 417 }, 409 418 "Label already exists, skipping duplicate", ··· 430 439 (checkAccountLabels as any).mockResolvedValueOnce(false); 431 440 432 441 await checkAccountAge({ 442 + actorDid: "did:plc:newlabel", 433 443 replyToDid: "did:plc:monitored", 434 444 replyingDid: "did:plc:newlabel", 435 445 atURI: TEST_REPLY_URI, ··· 460 470 GLOBAL_ALLOW.push("did:plc:allowlisted"); 461 471 462 472 await checkAccountAge({ 473 + actorDid: "did:plc:allowlisted", 463 474 replyToDid: "did:plc:monitored", 464 475 replyingDid: "did:plc:allowlisted", 465 476 atURI: TEST_REPLY_URI, ··· 491 502 }); 492 503 493 504 await checkAccountAge({ 505 + actorDid: "did:plc:newaccount", 494 506 replyToDid: "did:plc:monitored", 495 507 replyingDid: "did:plc:newaccount", 496 508 atURI: TEST_REPLY_URI, ··· 525 537 (checkAccountLabels as any).mockResolvedValueOnce(false); 526 538 527 539 await checkAccountAge({ 540 + actorDid: "did:plc:newaccount", 528 541 replyToDid: "did:plc:monitored", 529 542 replyingDid: "did:plc:newaccount", 530 543 atURI: TEST_REPLY_URI, ··· 559 572 (checkAccountLabels as any).mockResolvedValueOnce(false); 560 573 561 574 await checkAccountAge({ 575 + actorDid: "did:plc:newaccount", 562 576 replyToDid: "did:plc:monitored", 563 577 replyingDid: "did:plc:newaccount", 564 578 atURI: TEST_REPLY_URI, ··· 594 608 (checkAccountLabels as any).mockResolvedValueOnce(false); 595 609 596 610 await checkAccountAge({ 611 + actorDid: "did:plc:newaccount", 597 612 replyToDid: "did:plc:someone", 598 613 replyingDid: "did:plc:newaccount", 599 614 atURI: TEST_REPLY_URI, ··· 627 642 }); 628 643 629 644 await checkAccountAge({ 645 + actorDid: "did:plc:newaccount", 630 646 replyToDid: "did:plc:someone", 631 647 replyingDid: "did:plc:newaccount", 632 648 atURI: TEST_REPLY_URI, ··· 661 677 662 678 // Test matching by post URI even though DID doesn't match 663 679 await checkAccountAge({ 680 + actorDid: "did:plc:newaccount", 664 681 replyToDid: "did:plc:someone-else", 665 682 replyingDid: "did:plc:newaccount", 666 683 atURI: TEST_REPLY_URI, ··· 696 713 (checkAccountLabels as any).mockResolvedValueOnce(false); 697 714 698 715 await checkAccountAge({ 716 + actorDid: "did:plc:newaccount", 699 717 replyToDid: "did:plc:monitored", 700 718 replyingDid: "did:plc:newaccount", 701 719 atURI: TEST_REPLY_URI, ··· 706 724 expect(createAccountLabel).toHaveBeenCalledWith( 707 725 "did:plc:newaccount", 708 726 "window-reply", 727 + expect.stringContaining("Account created within monitored range"), 728 + ); 729 + }); 730 + 731 + it("should label account when quoting a monitored DID", async () => { 732 + ACCOUNT_AGE_CHECKS.push({ 733 + monitoredDIDs: ["did:plc:monitored"], 734 + anchorDate: "2025-10-15", 735 + maxAgeDays: 7, 736 + label: "quote-brigading", 737 + comment: "New account quote-posting monitored user", 738 + }); 739 + 740 + // Mock account created within window 741 + const mockDidDoc = [{ createdAt: "2025-10-18T12:00:00.000Z" }]; 742 + (global.fetch as any).mockResolvedValueOnce({ 743 + ok: true, 744 + json: async () => mockDidDoc, 745 + }); 746 + 747 + // Mock that label does NOT exist 748 + (checkAccountLabels as any).mockResolvedValueOnce(false); 749 + 750 + await checkAccountAge({ 751 + actorDid: "did:plc:quoter", 752 + quotedDid: "did:plc:monitored", 753 + atURI: "at://did:plc:quoter/app.bsky.feed.post/quote123", 754 + time: TEST_TIME, 755 + }); 756 + 757 + expect(createAccountLabel).toHaveBeenCalledWith( 758 + "did:plc:quoter", 759 + "quote-brigading", 760 + expect.stringContaining("Account created within monitored range"), 761 + ); 762 + }); 763 + 764 + it("should label account when quoting a monitored post URI", async () => { 765 + ACCOUNT_AGE_CHECKS.push({ 766 + monitoredPostURIs: [ 767 + "at://did:plc:target/app.bsky.feed.post/targeted", 768 + ], 769 + anchorDate: "2025-10-15", 770 + maxAgeDays: 7, 771 + label: "brigading-suspect", 772 + comment: "New account quoting targeted post", 773 + }); 774 + 775 + // Mock account created within window 776 + const mockDidDoc = [{ createdAt: "2025-10-18T12:00:00.000Z" }]; 777 + (global.fetch as any).mockResolvedValueOnce({ 778 + ok: true, 779 + json: async () => mockDidDoc, 780 + }); 781 + 782 + // Mock that label does NOT exist 783 + (checkAccountLabels as any).mockResolvedValueOnce(false); 784 + 785 + await checkAccountAge({ 786 + actorDid: "did:plc:quoter", 787 + quotedPostURI: "at://did:plc:target/app.bsky.feed.post/targeted", 788 + atURI: "at://did:plc:quoter/app.bsky.feed.post/quote456", 789 + time: TEST_TIME, 790 + }); 791 + 792 + expect(createAccountLabel).toHaveBeenCalledWith( 793 + "did:plc:quoter", 794 + "brigading-suspect", 795 + expect.stringContaining("Account created within monitored range"), 796 + ); 797 + }); 798 + 799 + it("should not label when quoting different DID", async () => { 800 + ACCOUNT_AGE_CHECKS.push({ 801 + monitoredDIDs: ["did:plc:monitored"], 802 + anchorDate: "2025-10-15", 803 + maxAgeDays: 7, 804 + label: "quote-brigading", 805 + comment: "New account quote-posting", 806 + }); 807 + 808 + // Mock account created within window 809 + const mockDidDoc = [{ createdAt: "2025-10-18T12:00:00.000Z" }]; 810 + (global.fetch as any).mockResolvedValueOnce({ 811 + ok: true, 812 + json: async () => mockDidDoc, 813 + }); 814 + 815 + await checkAccountAge({ 816 + actorDid: "did:plc:quoter", 817 + quotedDid: "did:plc:different", 818 + atURI: "at://did:plc:quoter/app.bsky.feed.post/quote789", 819 + time: TEST_TIME, 820 + }); 821 + 822 + expect(createAccountLabel).not.toHaveBeenCalled(); 823 + }); 824 + 825 + it("should match either reply or quote to monitored DID", async () => { 826 + ACCOUNT_AGE_CHECKS.push({ 827 + monitoredDIDs: ["did:plc:monitored"], 828 + anchorDate: "2025-10-15", 829 + maxAgeDays: 7, 830 + label: "interaction-suspect", 831 + comment: "New account interacting with monitored user", 832 + }); 833 + 834 + // Mock account created within window 835 + const mockDidDoc = [{ createdAt: "2025-10-18T12:00:00.000Z" }]; 836 + (global.fetch as any).mockResolvedValueOnce({ 837 + ok: true, 838 + json: async () => mockDidDoc, 839 + }); 840 + 841 + // Mock that label does NOT exist 842 + (checkAccountLabels as any).mockResolvedValueOnce(false); 843 + 844 + // Test quote (not reply) to monitored DID 845 + await checkAccountAge({ 846 + actorDid: "did:plc:actor", 847 + quotedDid: "did:plc:monitored", 848 + atURI: "at://did:plc:actor/app.bsky.feed.post/interaction", 849 + time: TEST_TIME, 850 + }); 851 + 852 + expect(createAccountLabel).toHaveBeenCalledWith( 853 + "did:plc:actor", 854 + "interaction-suspect", 709 855 expect.stringContaining("Account created within monitored range"), 710 856 ); 711 857 });