diff --git a/app/lib/posthog.tsx b/app/lib/posthog.tsx index c262f4d..ecbbfef 100644 --- a/app/lib/posthog.tsx +++ b/app/lib/posthog.tsx @@ -1,5 +1,5 @@ import { useLocation } from "@remix-run/react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { User } from "@prisma/client"; import { posthog } from "posthog-js"; @@ -28,7 +28,7 @@ export function usePosthog(props: { user: User | null; enabled: boolean }) { }, [props.enabled, props.user]); useEffect(() => { - if (enabled) { + if (props.enabled) { posthog.capture("$pageview"); } }, [props.enabled, location]); diff --git a/app/lib/validations.server.ts b/app/lib/validations.server.ts index f632180..8771c89 100644 --- a/app/lib/validations.server.ts +++ b/app/lib/validations.server.ts @@ -5,9 +5,11 @@ import { ban, cooldown, hideQuietly, + isCohost, mute, warnAndHide, } from "./warpcast.server"; +import { ModeratedChannel } from "@prisma/client"; export type RuleDefinition = { friendlyName: string; @@ -169,6 +171,14 @@ export const ruleDefinitions: Record = { args: {}, }, + userIsCohost: { + friendlyName: "User Is Cohost", + description: "Check if the user is a cohost", + hidden: false, + invertable: false, + args: {}, + }, + userFidInRange: { friendlyName: "User FID In Range", description: "Check if the user's FID is within a range", @@ -285,6 +295,7 @@ export const ruleNames = [ "userFollowerCount", "userIsNotActive", "userFidInRange", + "userIsCohost", ] as const; export const actionTypes = [ @@ -302,7 +313,13 @@ export const actionTypes = [ export type RuleName = (typeof ruleNames)[number]; export type ActionType = (typeof actionTypes)[number]; -export type CheckFunction = (cast: Cast, rule: Rule) => string | undefined; +export type CheckFunctionArgs = { + channel: ModeratedChannel; + cast: Cast; + rule: Rule; +}; + +export type CheckFunction = (props: CheckFunctionArgs) => string | undefined; export type ActionFunction = (args: { channel: string; cast: Cast; @@ -377,6 +394,7 @@ export const ruleFunctions: Record = { containsTooManyMentions: containsTooManyMentions, containsLinks: containsLinks, userProfileContainsText: userProfileContainsText, + userIsCohost: userIsCohost, userDisplayNameContainsText: userDisplayNameContainsText, userFollowerCount: userFollowerCount, userIsNotActive: userIsNotActive, @@ -396,7 +414,8 @@ export const actionFunctions: Record = { } as const; // Rule: contains text, option to ignore case -export function containsText(cast: Cast, rule: Rule) { +export function containsText(props: CheckFunctionArgs) { + const { cast, rule } = props; const { searchText, caseSensitive } = rule.args; const text = caseSensitive ? cast.text : cast.text.toLowerCase(); @@ -409,7 +428,8 @@ export function containsText(cast: Cast, rule: Rule) { } } -export function textMatchesPattern(cast: Cast, rule: Rule) { +export function textMatchesPattern(args: CheckFunctionArgs) { + const { cast, rule } = args; const { pattern } = rule.args; const re2 = new RE2(pattern); @@ -424,7 +444,8 @@ export function textMatchesPattern(cast: Cast, rule: Rule) { } // Rule: contains too many mentions (@...) -export function containsTooManyMentions(cast: Cast, rule: Rule) { +export function containsTooManyMentions(args: CheckFunctionArgs) { + const { cast, rule } = args; const { maxMentions } = rule.args; const mentions = cast.text.match(/@\w+/g) || []; @@ -436,7 +457,8 @@ export function containsTooManyMentions(cast: Cast, rule: Rule) { } } -export function containsLinks(cast: Cast, rule: Rule) { +export function containsLinks(args: CheckFunctionArgs) { + const { cast, rule } = args; const maxLinks = rule.args.maxLinks || 0; const regex = /https?:\/\/\S+/gi; const matches = cast.text.match(regex) || []; @@ -448,7 +470,8 @@ export function containsLinks(cast: Cast, rule: Rule) { } } -export function userProfileContainsText(cast: Cast, rule: Rule) { +export function userProfileContainsText(args: CheckFunctionArgs) { + const { cast, rule } = args; const { searchText, caseSensitive } = rule.args; const containsText = !caseSensitive ? cast.author.profile.bio.text @@ -463,7 +486,8 @@ export function userProfileContainsText(cast: Cast, rule: Rule) { } } -export function userDisplayNameContainsText(cast: Cast, rule: Rule) { +export function userDisplayNameContainsText(args: CheckFunctionArgs) { + const { cast, rule } = args; const { searchText, caseSensitive } = rule.args; const containsText = !caseSensitive ? cast.author.display_name.toLowerCase().includes(searchText.toLowerCase()) @@ -476,7 +500,8 @@ export function userDisplayNameContainsText(cast: Cast, rule: Rule) { } } -export function userFollowerCount(cast: Cast, rule: Rule) { +export function userFollowerCount(props: CheckFunctionArgs) { + const { cast, rule } = props; const { min, max } = rule.args as { min?: number; max?: number }; if (min) { @@ -492,8 +517,22 @@ export function userFollowerCount(cast: Cast, rule: Rule) { } } +export function userIsCohost(args: CheckFunctionArgs) { + const { channel } = args; + + const isUserCohost = isCohost({ + fid: args.cast.author.fid, + channel: channel.id, + }); + + if (!isUserCohost) { + return `User is not a cohost`; + } +} + // Rule: user active_status must be active -export function userIsNotActive(cast: Cast, rule: Rule) { +export function userIsNotActive(args: CheckFunctionArgs) { + const { cast, rule } = args; if (!rule.invert && cast.author.active_status !== "active") { return `User is not active`; } else if (rule.invert && cast.author.active_status === "active") { @@ -502,7 +541,8 @@ export function userIsNotActive(cast: Cast, rule: Rule) { } // Rule: user fid must be in range -export function userFidInRange(cast: Cast, rule: Rule) { +export function userFidInRange(args: CheckFunctionArgs) { + const { cast, rule } = args; const { minFid, maxFid } = rule.args as { minFid?: number; maxFid?: number }; if (minFid) { diff --git a/app/routes/api.webhooks.neynar.tsx b/app/routes/api.webhooks.neynar.tsx index 9c8f1ad..b1c7c66 100644 --- a/app/routes/api.webhooks.neynar.tsx +++ b/app/routes/api.webhooks.neynar.tsx @@ -1,5 +1,5 @@ import { Cast, Channel } from "@neynar/nodejs-sdk/build/neynar-api/v2"; -import { Prisma } from "@prisma/client"; +import { ModeratedChannel, Prisma } from "@prisma/client"; import { ActionFunctionArgs, json } from "@remix-run/node"; import { db } from "~/lib/db.server"; import { getChannel } from "~/lib/neynar.server"; @@ -223,7 +223,7 @@ export async function validateCast({ const rule: Rule = JSON.parse(ruleSet.rule); const actions: Action[] = JSON.parse(ruleSet.actions); - const ruleEvaluation = evaluateRules(cast, rule); + const ruleEvaluation = evaluateRules(moderatedChannel, cast, rule); if (ruleEvaluation.didViolateRule) { /** @@ -334,6 +334,7 @@ async function logModerationAction( } function evaluateRules( + moderatedChannel: ModeratedChannel, cast: Cast, rule: Rule ): @@ -346,11 +347,11 @@ function evaluateRules( didViolateRule: false; } { if (rule.type === "CONDITION") { - return evaluateRule(cast, rule); + return evaluateRule(moderatedChannel, cast, rule); } else if (rule.type === "LOGICAL" && rule.conditions) { if (rule.operation === "AND") { const evaluations = rule.conditions.map((subRule) => - evaluateRules(cast, subRule) + evaluateRules(moderatedChannel, cast, subRule) ); if (evaluations.every((e) => e.didViolateRule)) { return { @@ -367,7 +368,7 @@ function evaluateRules( } } else if (rule.operation === "OR") { const results = rule.conditions.map((subRule) => - evaluateRules(cast, subRule) + evaluateRules(moderatedChannel, cast, subRule) ); const violation = results.find((r) => r.didViolateRule); @@ -383,6 +384,7 @@ function evaluateRules( } function evaluateRule( + channel: ModeratedChannel, cast: Cast, rule: Rule ): @@ -395,7 +397,7 @@ function evaluateRule( didViolateRule: false; } { const check = ruleFunctions[rule.name]; - const error = check(cast, rule); + const error = check({ channel, cast, rule }); if (error) { return { diff --git a/tests/unit/validations.test.ts b/tests/unit/validations.test.ts index e418608..c78b7e7 100644 --- a/tests/unit/validations.test.ts +++ b/tests/unit/validations.test.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { assert, it, expect, describe, test } from "vitest"; import { faker } from "@faker-js/faker"; -import { User } from "@prisma/client"; +import { ModeratedChannel, User } from "@prisma/client"; import { Channel as NeynarChannel, User as NeynarUser, @@ -21,6 +21,22 @@ import { userProfileContainsText, } from "~/lib/validations.server"; +export function moderatedChannel( + data?: Partial +): ModeratedChannel { + return { + id: faker.string.uuid(), + imageUrl: faker.internet.userName(), + active: true, + url: faker.internet.url(), + userId: faker.string.uuid(), + banThreshold: null, + createdAt: faker.date.past(), + updatedAt: faker.date.recent(), + ...data, + }; +} + export function user(data?: Partial): User { return { id: faker.string.uuid(), @@ -132,17 +148,28 @@ export function action(overrides?: Partial): Action { } as any; } +const m = moderatedChannel({ + id: "gm", + userId: "1", + imageUrl: "https://example.com/image.jpg", + url: "https://example.com/channel", +}); + describe("containsText", () => { it("should detect text correctly without case sensitivity", () => { const c = cast({ text: "Example Text" }); const r = rule({ args: { searchText: "example", caseSensitive: false } }); - expect(containsText(c, r)).toBe(`Text contains the text: example`); + expect(containsText({ channel: m, cast: c, rule: r })).toBe( + `Text contains the text: example` + ); }); it("should detect text correctly with case sensitivity", () => { const c = cast({ text: "Example Text" }); const r = rule({ args: { searchText: "Example", caseSensitive: true } }); - expect(containsText(c, r)).toBe(`Text contains the text: Example`); + expect(containsText({ channel: m, cast: c, rule: r })).toBe( + `Text contains the text: Example` + ); }); it("should invert the check if the rule is inverted", () => { @@ -151,13 +178,13 @@ describe("containsText", () => { invert: true, args: { searchText: "example", caseSensitive: false }, }); - expect(containsText(c, r)).toBeUndefined(); + expect(containsText({ channel: m, cast: c, rule: r })).toBeUndefined(); }); it("should return undefined if text is not found", () => { const c = cast({ text: "Example Text" }); const r = rule({ args: { searchText: "notfound", caseSensitive: false } }); - expect(containsText(c, r)).toBeUndefined(); + expect(containsText({ channel: m, cast: c, rule: r })).toBeUndefined(); }); }); @@ -165,7 +192,7 @@ describe("containsTooManyMentions", () => { it("should return a message if there are too many mentions", () => { const c = cast({ text: "@user1 @user2 @user3" }); const r = rule({ args: { maxMentions: 2 } }); - expect(containsTooManyMentions(c, r)).toBe( + expect(containsTooManyMentions({ channel: m, cast: c, rule: r })).toBe( "Too many mentions: @user1,@user2,@user3. Max: 2" ); }); @@ -173,13 +200,17 @@ describe("containsTooManyMentions", () => { it("should return undefined if the mentions are within limit", () => { const c = cast({ text: "@user1 @user2" }); const r = rule({ args: { maxMentions: 3 } }); - expect(containsTooManyMentions(c, r)).toBeUndefined(); + expect( + containsTooManyMentions({ channel: m, cast: c, rule: r }) + ).toBeUndefined(); }); it("should invert the check if the rule is inverted", () => { const c = cast({ text: "@user1 @user2 @user3" }); const r = rule({ args: { maxMentions: 2 }, invert: true }); - expect(containsTooManyMentions(c, r)).toBeUndefined(); + expect( + containsTooManyMentions({ channel: m, cast: c, rule: r }) + ).toBeUndefined(); }); }); @@ -187,7 +218,9 @@ describe("containsLinks", () => { it("should detect links in the text", () => { const c = cast({ text: "Check out this link https://example.com" }); const r = rule({}); - expect(containsLinks(c, r)).toBe("Too many links. Max: 0"); + expect(containsLinks({ channel: m, cast: c, rule: r })).toBe( + "Too many links. Max: 0" + ); }); it("should detect links based on a threshold", () => { @@ -196,22 +229,24 @@ describe("containsLinks", () => { }); const r1 = rule({ args: { maxLinks: 2 } }); - expect(containsLinks(c, r1)).toBeUndefined(); + expect(containsLinks({ channel: m, cast: c, rule: r1 })).toBeUndefined(); const r2 = rule({ args: { maxLinks: 1 } }); - expect(containsLinks(c, r2)).toBe("Too many links. Max: 1"); + expect(containsLinks({ channel: m, cast: c, rule: r2 })).toBe( + "Too many links. Max: 1" + ); }); it("should invert the check if the rule is inverted", () => { const c = cast({ text: "Check out this link https://example.com" }); const r = rule({ args: { maxLinks: 0 }, invert: true }); - expect(containsLinks(c, r)).toBeUndefined(); + expect(containsLinks({ channel: m, cast: c, rule: r })).toBeUndefined(); }); it("should return undefined if no links are found", () => { const c = cast({ text: "No links here" }); const r = rule({}); - expect(containsLinks(c, r)).toBeUndefined(); + expect(containsLinks({ channel: m, cast: c, rule: r })).toBeUndefined(); }); }); @@ -222,7 +257,7 @@ describe("userProfileContainsText", () => { }); const r = rule({ args: { searchText: "developer", caseSensitive: false } }); - expect(userProfileContainsText(c, r)).toBe( + expect(userProfileContainsText({ channel: m, cast: c, rule: r })).toBe( "User profile contains the specified text: developer" ); }); @@ -232,7 +267,7 @@ describe("userProfileContainsText", () => { author: { profile: { bio: { text: "Developer and writer." } } }, } as any); const r = rule({ args: { searchText: "Developer", caseSensitive: true } }); - expect(userProfileContainsText(c, r)).toBe( + expect(userProfileContainsText({ channel: m, cast: c, rule: r })).toBe( "User profile contains the specified text: Developer" ); }); @@ -244,7 +279,9 @@ describe("userProfileContainsText", () => { const r = rule({ args: { searchText: "developer", caseSensitive: false, invert: true }, }); - expect(userProfileContainsText(c, r)).toBeUndefined(); + expect( + userProfileContainsText({ channel: m, cast: c, rule: r }) + ).toBeUndefined(); }); it("should return undefined if text is not found in user profile bio", () => { @@ -252,7 +289,9 @@ describe("userProfileContainsText", () => { author: { profile: { bio: { text: "Developer and writer." } } }, } as any); const r = rule({ args: { searchText: "artist", caseSensitive: false } }); - expect(userProfileContainsText(c, r)).toBeUndefined(); + expect( + userProfileContainsText({ channel: m, cast: c, rule: r }) + ).toBeUndefined(); }); }); @@ -260,7 +299,7 @@ describe("userDisplayNameContainsText", () => { it("should detect text in user display name without case sensitivity", () => { const c = cast({ author: { display_name: "JohnDoe" } as any }); const r = rule({ args: { searchText: "johndoe", caseSensitive: false } }); - expect(userDisplayNameContainsText(c, r)).toBe( + expect(userDisplayNameContainsText({ channel: m, cast: c, rule: r })).toBe( "User display name contains text: johndoe" ); }); @@ -268,7 +307,7 @@ describe("userDisplayNameContainsText", () => { it("should detect text in user display name with case sensitivity", () => { const c = cast({ author: { display_name: "JohnDoe" } as any }); const r = rule({ args: { searchText: "JohnDoe", caseSensitive: true } }); - expect(userDisplayNameContainsText(c, r)).toBe( + expect(userDisplayNameContainsText({ channel: m, cast: c, rule: r })).toBe( "User display name contains text: JohnDoe" ); }); @@ -278,13 +317,17 @@ describe("userDisplayNameContainsText", () => { const r = rule({ args: { searchText: "sean", caseSensitive: false, invert: true }, }); - expect(userDisplayNameContainsText(c, r)).toBeUndefined(); + expect( + userDisplayNameContainsText({ channel: m, cast: c, rule: r }) + ).toBeUndefined(); }); it("should return undefined if text is not found in user display name", () => { const c = cast({ author: { display_name: "JohnDoe" } as any }); const r = rule({ args: { searchText: "JaneDoe", caseSensitive: false } }); - expect(userDisplayNameContainsText(c, r)).toBeUndefined(); + expect( + userDisplayNameContainsText({ channel: m, cast: c, rule: r }) + ).toBeUndefined(); }); }); @@ -292,19 +335,23 @@ describe("userFollowerCount", () => { it("should return message if follower count is less than minimum", () => { const c = cast({ author: { follower_count: 50 } as any }); const r = rule({ args: { min: 100 } }); - expect(userFollowerCount(c, r)).toBe("Follower count less than 100"); + expect(userFollowerCount({ channel: m, cast: c, rule: r })).toBe( + "Follower count less than 100" + ); }); it("should return message if follower count is greater than maximum", () => { const c = cast({ author: { follower_count: 500 } as any }); const r = rule({ args: { max: 300 } }); - expect(userFollowerCount(c, r)).toBe("Follower count greater than 300"); + expect(userFollowerCount({ channel: m, cast: c, rule: r })).toBe( + "Follower count greater than 300" + ); }); it("should return undefined if follower count is within the specified range", () => { const c = cast({ author: { follower_count: 200 } as any }); const r = rule({ args: { min: 100, max: 300 } }); - expect(userFollowerCount(c, r)).toBeUndefined(); + expect(userFollowerCount({ channel: m, cast: c, rule: r })).toBeUndefined(); }); }); @@ -312,19 +359,23 @@ describe("userIsNotActive", () => { it("should return undefined if user is active", () => { const c = cast({ author: { active_status: "active" } as any }); const r = rule({}); - expect(userIsNotActive(c, r)).toBeUndefined(); + expect(userIsNotActive({ channel: m, cast: c, rule: r })).toBeUndefined(); }); it('should invert the rule if "invert" is set', () => { const c = cast({ author: { active_status: "active" } as any }); const r = rule({ invert: true }); - expect(userIsNotActive(c, r)).toBe("User is active"); + expect(userIsNotActive({ channel: m, cast: c, rule: r })).toBe( + "User is active" + ); }); it("should return a message if user is not active", () => { const c = cast({ author: { active_status: "inactive" } as any }); const r = rule({}); - expect(userIsNotActive(c, r)).toBe("User is not active"); + expect(userIsNotActive({ channel: m, cast: c, rule: r })).toBe( + "User is not active" + ); }); }); @@ -332,18 +383,22 @@ describe("userFidInRange", () => { it("should return message if FID is less than minimum", () => { const c = cast({ author: { fid: 500 } as any }); const r = rule({ args: { minFid: 1000 } }); - expect(userFidInRange(c, r)).toBe("FID 500 is less than 1000"); + expect(userFidInRange({ channel: m, cast: c, rule: r })).toBe( + "FID 500 is less than 1000" + ); }); it("should return message if FID is greater than maximum", () => { const c = cast({ author: { fid: 2000 } as any }); const r = rule({ args: { maxFid: 1500 } }); - expect(userFidInRange(c, r)).toBe("FID 2000 is greater than 1500"); + expect(userFidInRange({ channel: m, cast: c, rule: r })).toBe( + "FID 2000 is greater than 1500" + ); }); it("should return undefined if FID is within the specified range", () => { const c = cast({ author: { fid: 1200 } as any }); const r = rule({ args: { minFid: 1000, maxFid: 1500 } }); - expect(userFidInRange(c, r)).toBeUndefined(); + expect(userFidInRange({ channel: m, cast: c, rule: r })).toBeUndefined(); }); });