87 | >(({ className, ...props }, ref) => (
88 | [role=checkbox]]:translate-y-[2px]",
92 | className
93 | )}
94 | {...props}
95 | />
96 | ))
97 | TableCell.displayName = "TableCell"
98 |
99 | const TableCaption = React.forwardRef<
100 | HTMLTableCaptionElement,
101 | React.HTMLAttributes
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | TableCaption.displayName = "TableCaption"
110 |
111 | export {
112 | Table,
113 | TableHeader,
114 | TableBody,
115 | TableFooter,
116 | TableHead,
117 | TableRow,
118 | TableCell,
119 | TableCaption,
120 | }
121 |
--------------------------------------------------------------------------------
/app/components/ui/tabs.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TabsPrimitive from "@radix-ui/react-tabs"
3 |
4 | import { cn } from "~/lib/utils"
5 |
6 | const Tabs = TabsPrimitive.Root
7 |
8 | const TabsList = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | TabsList.displayName = TabsPrimitive.List.displayName
22 |
23 | const TabsTrigger = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
35 | ))
36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
37 |
38 | const TabsContent = React.forwardRef<
39 | React.ElementRef,
40 | React.ComponentPropsWithoutRef
41 | >(({ className, ...props }, ref) => (
42 |
50 | ))
51 | TabsContent.displayName = TabsPrimitive.Content.displayName
52 |
53 | export { Tabs, TabsList, TabsTrigger, TabsContent }
54 |
--------------------------------------------------------------------------------
/app/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "~/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/app/components/ui/tooltip.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"
3 |
4 | import { cn } from "~/lib/utils"
5 |
6 | const TooltipProvider = TooltipPrimitive.Provider
7 |
8 | const Tooltip = TooltipPrimitive.Root
9 |
10 | const TooltipTrigger = TooltipPrimitive.Trigger
11 |
12 | const TooltipContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, sideOffset = 4, ...props }, ref) => (
16 |
25 | ))
26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName
27 |
28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
29 |
--------------------------------------------------------------------------------
/app/entry.client.tsx:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/remix";
2 | import { RemixBrowser, useLocation, useMatches } from "@remix-run/react";
3 | import { startTransition, StrictMode, useEffect } from "react";
4 | import { hydrateRoot } from "react-dom/client";
5 |
6 | Sentry.init({
7 | dsn: "https://5473e1495403d0b381b2556a80b3775f@o4506520941756416.ingest.sentry.io/4506827315937280",
8 | tracesSampleRate: 0,
9 | replaysSessionSampleRate: 0,
10 | replaysOnErrorSampleRate: 0.1,
11 | enabled: false,
12 | integrations: [
13 | Sentry.browserTracingIntegration({
14 | useEffect,
15 | useLocation,
16 | useMatches,
17 | }),
18 | ],
19 | });
20 |
21 | startTransition(() => {
22 | hydrateRoot(
23 | document,
24 |
25 |
26 |
27 | );
28 | });
29 |
--------------------------------------------------------------------------------
/app/lib/alchemy.server.ts:
--------------------------------------------------------------------------------
1 | import { Alchemy } from "alchemy-sdk";
2 |
3 | export const alchemy = new Alchemy({
4 | apiKey: process.env.ALCHEMY_PROJECT_ID!,
5 | });
6 |
--------------------------------------------------------------------------------
/app/lib/auth.server.ts:
--------------------------------------------------------------------------------
1 | import type { User } from "@prisma/client";
2 | import * as Sentry from "@sentry/remix";
3 | import { Authenticator } from "remix-auth";
4 | import { db } from "~/lib/db.server";
5 | import { createCookie, createCookieSessionStorage } from "@remix-run/node";
6 | import { FarcasterStrategy } from "./farcaster-strategy";
7 | import { OtpStrategy } from "./otp-strategy";
8 | import { GodStrategy } from "./god-strategy";
9 | import { getSubscriptionPlan } from "./subscription.server";
10 |
11 | export const sessionStorage = createCookieSessionStorage({
12 | cookie: {
13 | name: "_session",
14 | sameSite: "lax",
15 | path: "/",
16 | httpOnly: true,
17 | maxAge: 1000 * 60 * 60 * 24 * 30, // 30 days
18 | secrets: [process.env.SESSION_SECRET || "STRONG_SECRET"],
19 | secure: process.env.NODE_ENV === "production",
20 | },
21 | });
22 |
23 | export const redirectCookie = createCookie("redirectTo", {
24 | path: "/",
25 | httpOnly: true,
26 | sameSite: "lax",
27 | maxAge: 60,
28 | secure: process.env.NODE_ENV === "production",
29 | });
30 |
31 | export const { getSession, commitSession, destroySession } = sessionStorage;
32 |
33 | export const authenticator = new Authenticator(sessionStorage, {
34 | throwOnError: true,
35 | });
36 |
37 | authenticator.use(
38 | new GodStrategy(async ({ username }) => {
39 | return db.user.findFirstOrThrow({
40 | where: {
41 | name: username,
42 | },
43 | });
44 | })
45 | );
46 |
47 | authenticator.use(
48 | new OtpStrategy(async ({ code }) => {
49 | const otp = await db.otp.findFirst({
50 | where: {
51 | code,
52 | active: true,
53 | },
54 | });
55 |
56 | if (!otp) {
57 | throw new Error("Invalid code");
58 | }
59 |
60 | await db.otp.update({
61 | where: {
62 | id: otp.id,
63 | },
64 | data: {
65 | active: false,
66 | },
67 | });
68 |
69 | const user = await db.user.findFirstOrThrow({
70 | where: {
71 | id: otp.userId,
72 | },
73 | });
74 |
75 | return user;
76 | })
77 | );
78 |
79 | export type FarcasterUser = {
80 | inviteCodeId?: string;
81 | fid: string;
82 | username?: string;
83 | pfpUrl?: string;
84 | };
85 |
86 | authenticator.use(new FarcasterStrategy(verifyFarcasterUser));
87 |
88 | export async function verifyFarcasterUser(args: FarcasterUser & { request: Request }) {
89 | const user = await db.user.findFirst({
90 | where: {
91 | id: args.fid,
92 | },
93 | });
94 |
95 | if (!user) {
96 | // const order = await db.order.findFirst({
97 | // where: {
98 | // fid: args.fid,
99 | // },
100 | // });
101 |
102 | // let subscription: Awaited> | undefined;
103 | // if (!order) {
104 | // subscription = await getSubscriptionPlan({ fid: args.fid });
105 |
106 | // if (subscription.tokenId) {
107 | // const existingUser = await db.user.findFirst({
108 | // where: {
109 | // planTokenId: subscription.tokenId,
110 | // plan: subscription.plan,
111 | // },
112 | // });
113 |
114 | // if (existingUser) {
115 | // Sentry.captureMessage(
116 | // `Token ${subscription.tokenId} for ${subscription.plan} already in use by ${existingUser.name}.`
117 | // );
118 | // throw new Error(`Token already in use. Contact support.`);
119 | // }
120 | // }
121 | // }
122 |
123 |
124 | return await db.user.create({
125 | data: {
126 | id: args.fid,
127 | plan: "basic",
128 | planExpiry: null,
129 | planTokenId: null,
130 | name: args.username || args.fid,
131 | avatarUrl: args.pfpUrl,
132 | inviteCodeId: args.inviteCodeId,
133 | },
134 | });
135 | }
136 |
137 | return user;
138 | }
139 |
--------------------------------------------------------------------------------
/app/lib/authkey.server.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/ban-ts-comment */
2 | // @ts-nocheck
3 | import { NobleEd25519Signer } from "@farcaster/hub-nodejs";
4 |
5 | export async function generateAuthToken() {
6 | try {
7 | const fid = 861203;
8 | const privateKey = `${process.env.MOD_PRIVATE_KEY}` as unknown as Uint8Array;
9 | const publicKey = `${process.env.MOD_PUBLIC_KEY}`;
10 |
11 | const signer = new NobleEd25519Signer(privateKey);
12 |
13 | const header = {
14 | fid,
15 | type: "app_key",
16 | key: publicKey,
17 | };
18 | const encodedHeader = Buffer.from(JSON.stringify(header)).toString("base64url");
19 | const payload = { exp: Math.floor(Date.now() / 1000) + 300 }; // 5 minutes
20 | const encodedPayload = Buffer.from(JSON.stringify(payload)).toString("base64url");
21 |
22 | const signatureResult = await signer.signMessageHash(Buffer.from(`${encodedHeader}.${encodedPayload}`, "utf-8"));
23 | const encodedSignature = Buffer.from(signatureResult.value).toString("base64url");
24 |
25 | const authToken = `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
26 | return authToken;
27 | } catch (error) {
28 | console.log(error);
29 | return error;
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/lib/bullish.server.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { Job, Queue, Worker } from "bullmq";
3 | import * as Sentry from "@sentry/remix";
4 | import IORedis from "ioredis";
5 | import { SweepArgs } from "~/routes/~.channels.$id.tools";
6 |
7 | const connection = new IORedis(process.env.REDIS_URL || "redis://localhost:6379", {
8 | maxRetriesPerRequest: null,
9 | });
10 |
11 | export const webhookQueue = new Queue("webhookQueue", {
12 | connection,
13 | });
14 |
15 | export const webhookWorker = new Worker(
16 | "webhookQueue",
17 | async (job: Job<{ url: string }>) => {
18 | // wait 10 seconds
19 | await new Promise((resolve) => setTimeout(resolve, 10_000));
20 | console.log("webhook", job.data.url);
21 | },
22 | {
23 | connection,
24 | lockDuration: 30_000,
25 | concurrency: 25,
26 | autorun: !!process.env.ENABLE_QUEUES,
27 | }
28 | );
29 |
30 | webhookWorker.on("error", (err: Error) => {
31 | Sentry.captureException(err);
32 | });
33 |
34 | // sweeeeep
35 | export const sweepQueue = new Queue("sweepQueue", {
36 | connection,
37 | });
38 |
39 | export const sweepWorker = new Worker(
40 | "sweepQueue",
41 | async (job: Job) => {
42 | console.log("sweeping", job.data);
43 | },
44 | {
45 | connection,
46 | concurrency: 25,
47 | autorun: !!process.env.ENABLE_QUEUES,
48 | }
49 | );
50 |
51 | sweepWorker.on("error", Sentry.captureException);
52 | sweepWorker.on("active", (job) => {
53 | if (process.env.NODE_ENV === "development") {
54 | console.log(`[${job.data.channelId}] sweeping...`);
55 | }
56 | });
57 | sweepWorker.on("failed", (job, err) => {
58 | console.error(`[${job?.data.channelId}] failed`, err);
59 | });
60 |
61 | sweepWorker.on("completed", (job) => {
62 | console.log(`[${job.data.channelId}] sweep completed`);
63 | });
64 |
--------------------------------------------------------------------------------
/app/lib/cache.server.ts:
--------------------------------------------------------------------------------
1 | import NodeCache from "node-cache";
2 | export const cache = new NodeCache({
3 | stdTTL: process.env.NODE_ENV !== "production" ? 30 : 60,
4 | });
5 |
--------------------------------------------------------------------------------
/app/lib/cast-actions.server.ts:
--------------------------------------------------------------------------------
1 | import { CastAction } from "./types";
2 | import { getSharedEnv } from "./utils.server";
3 | import { actionDefinitions } from "./validations.server";
4 |
5 | const env = getSharedEnv();
6 |
7 | export const deprecatedActions = ["mute", "warnAndHide"];
8 |
9 | export const addToBypassAction = {
10 | action: {
11 | type: "post",
12 | },
13 | description: "Always curate casts from this user",
14 | name: "Bypass",
15 | automodAction: "addToBypass",
16 | icon: "shield-check",
17 | postUrl: `${env.hostUrl}/api/actions/addToBypass`,
18 | aboutUrl: "https://automod.sh",
19 | image: `${env.hostUrl}/actions/bypass.png`,
20 | } as const;
21 |
22 | export const cooldown24Action = {
23 | action: {
24 | type: "post",
25 | },
26 | description: "Hide all messages from a user for 24 hours",
27 | automodAction: "cooldown",
28 | name: "24h Cooldown",
29 | icon: "no-entry",
30 | postUrl: `${env.hostUrl}/api/actions/cooldown`,
31 | aboutUrl: "https://automod.sh",
32 | image: `${env.hostUrl}/actions/cooldown24.png`,
33 | } as const;
34 |
35 | export const banAction = {
36 | automodAction: "ban",
37 | action: {
38 | type: "post",
39 | },
40 | name: actionDefinitions["ban"].friendlyName,
41 | description: actionDefinitions["ban"].description,
42 | icon: "sign-out",
43 | postUrl: `${env.hostUrl}/api/actions/ban`,
44 | aboutUrl: "https://automod.sh",
45 | image: `${env.hostUrl}/actions/ban.png`,
46 | } as const;
47 |
48 | export const downvoteAction = {
49 | automodAction: "downvote",
50 | action: {
51 | type: "post",
52 | },
53 | name: actionDefinitions["downvote"].friendlyName,
54 | description: actionDefinitions["downvote"].description,
55 | icon: "thumbsdown",
56 | postUrl: `${env.hostUrl}/api/actions/downvote`,
57 | aboutUrl: "https://automod.sh",
58 | image: `${env.hostUrl}/actions/downvote.png`,
59 | } as const;
60 |
61 | export const likeAction = {
62 | automodAction: "like",
63 | action: {
64 | type: "post",
65 | },
66 | name: actionDefinitions["like"].friendlyName,
67 | description: actionDefinitions["like"].description,
68 | icon: "thumbsup",
69 | postUrl: `${env.hostUrl}/api/actions/like`,
70 | aboutUrl: "https://automod.sh",
71 | image: `${env.hostUrl}/actions/curate.png`,
72 | } as const;
73 |
74 | export const unlikeAction = {
75 | automodAction: "unlike",
76 | action: {
77 | type: "post",
78 | },
79 | name: actionDefinitions["unlike"].friendlyName,
80 | description: actionDefinitions["unlike"].description,
81 | icon: "eye-closed",
82 | postUrl: `${env.hostUrl}/api/actions/unlike`,
83 | aboutUrl: "https://automod.sh",
84 | image: `${env.hostUrl}/actions/hideQuietly.png`,
85 | } as const;
86 |
87 | export const actions = [
88 | addToBypassAction,
89 | cooldown24Action,
90 | banAction,
91 | downvoteAction,
92 | likeAction,
93 | unlikeAction,
94 | ] as const satisfies Array;
95 |
--------------------------------------------------------------------------------
/app/lib/cast-mod.server.ts:
--------------------------------------------------------------------------------
1 | import { Cast } from "@neynar/nodejs-sdk/build/neynar-api/v2";
2 | import { ModeratedChannel, ModerationLog } from "@prisma/client";
3 |
4 | import { Rule, User } from "~/rules/rules.type";
5 | import { ruleFunctions } from "~/lib/validations.server";
6 |
7 | export type ValidateCastArgs = {
8 | moderatedChannel: ModeratedChannel & { castRuleSetParsed: { ruleParsed: Rule } };
9 | cast: Cast;
10 | };
11 |
12 | export async function validateCast({ moderatedChannel, cast }: ValidateCastArgs) {
13 | const result = await evaluateRules(moderatedChannel, cast, moderatedChannel.castRuleSetParsed.ruleParsed);
14 | return result;
15 | }
16 |
17 | async function evaluateRules(
18 | moderatedChannel: ModeratedChannel,
19 | cast: Cast,
20 | rule: Rule
21 | ): Promise<{
22 | passedRule: boolean;
23 | explanation: string;
24 | rule: Rule;
25 | }> {
26 | if (rule.type === "CONDITION") {
27 | return evaluateRule(moderatedChannel, cast, rule);
28 | } else if (rule.type === "LOGICAL" && rule.conditions) {
29 | if (rule.operation === "AND") {
30 | const evaluations = await Promise.all(
31 | rule.conditions.map((subRule) => evaluateRules(moderatedChannel, cast, subRule))
32 | );
33 | if (evaluations.every((e) => e.passedRule)) {
34 | return {
35 | passedRule: true,
36 | explanation: `${evaluations.map((e) => e.explanation).join(", ")}`,
37 | rule,
38 | };
39 | } else {
40 | const failure = evaluations.find((e) => !e.passedRule)!;
41 | return { passedRule: false, explanation: `${failure.explanation}`, rule };
42 | }
43 | } else if (rule.operation === "OR") {
44 | const results: Array<{
45 | passedRule: boolean;
46 | explanation: string;
47 | rule: Rule;
48 | }> = [];
49 |
50 | for (const subRule of rule.conditions) {
51 | const result = await evaluateRules(moderatedChannel, cast, subRule);
52 | results.push(result);
53 | if (result.passedRule) {
54 | return result;
55 | }
56 | }
57 |
58 | const explanation =
59 | results.length > 1
60 | ? `Failed all checks: ${results.map((e) => e.explanation).join(", ")}`
61 | : results[0].explanation;
62 |
63 | return {
64 | passedRule: false,
65 | explanation,
66 | rule,
67 | };
68 | }
69 | }
70 |
71 | return { passedRule: false, explanation: "No rules", rule };
72 | }
73 |
74 | async function evaluateRule(
75 | channel: ModeratedChannel,
76 | cast: Cast,
77 | rule: Rule
78 | ): Promise<{ passedRule: boolean; explanation: string; rule: Rule }> {
79 | const check = ruleFunctions[rule.name];
80 | if (!check) {
81 | throw new Error(`No function for rule ${rule.name}`);
82 | }
83 |
84 | const result = await check({ channel, user: {} as unknown as User, cast, rule });
85 |
86 | return {
87 | passedRule: result.result,
88 | explanation: result.message,
89 | rule,
90 | };
91 | }
92 |
--------------------------------------------------------------------------------
/app/lib/castsense.server.ts:
--------------------------------------------------------------------------------
1 | import { User } from "@neynar/nodejs-sdk/build/neynar-api/v2";
2 | import axios from "axios";
3 | import { getSetCache } from "./utils.server";
4 |
5 | const baseUrl = `https://www.castsense.xyz`;
6 |
7 | export async function getChannelStats(props: { channelId: string }) {
8 | const cacheKey = `channelStats:${props.channelId}`;
9 | return getSetCache({
10 | key: cacheKey,
11 | ttlSeconds: 60 * 60 * 4,
12 | get: () =>
13 | axios.get(`${baseUrl}/api/channel/${props.channelId}/stats`).then((res) => res.data),
14 | });
15 | }
16 |
17 | export async function getTopEngagers(props: { channelId: string }) {
18 | const cacheKey = `topEngagers:${props.channelId}`;
19 | return getSetCache({
20 | key: cacheKey,
21 | ttlSeconds: 60 * 60 * 4,
22 | get: () => axios.get(`${baseUrl}/api/channel/${props.channelId}/top-engagers`).then((res) => res.data),
23 | });
24 | }
25 |
26 | export type TopEngagersResponse = {
27 | results: Array;
28 | };
29 |
30 | export type CastSenseResponse = {
31 | casts_percentage_change: number;
32 | current_period_casts: number;
33 | current_period_likes: number;
34 | current_period_mentions: null;
35 | current_period_recasts: number;
36 | current_period_replies: number;
37 | total_followers: number;
38 | likes_percentage_change: number;
39 | mentions_percentage_change: number | null;
40 | recasts_percentage_change: number;
41 | replies_percentage_change: number;
42 | churn_rate: number;
43 | };
44 |
--------------------------------------------------------------------------------
/app/lib/db.server.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | import { singleton } from "./singleton.server";
4 | import { Action } from "./validations.server";
5 | import { Permission } from "./permissions.server";
6 | import { RuleSet } from "./types";
7 | import { Rule, SelectOption } from "~/rules/rules.type";
8 |
9 | // Hard-code a unique key, so we can look up the client when this module gets re-imported
10 | const db = singleton("prisma", () =>
11 | new PrismaClient().$extends({
12 | result: {
13 | moderatedChannel: {
14 | inclusionRuleSetParsed: {
15 | needs: {
16 | inclusionRuleSet: true,
17 | },
18 |
19 | compute(data): (RuleSet & { ruleParsed: Rule; actionsParsed: Array }) | undefined {
20 | if (data.inclusionRuleSet) {
21 | const ruleSet = JSON.parse(data.inclusionRuleSet);
22 | ruleSet.ruleParsed = ruleSet.rule;
23 | ruleSet.actionsParsed = ruleSet.actions;
24 | return ruleSet;
25 | }
26 | },
27 | },
28 | castRuleSetParsed: {
29 | needs: {
30 | castRuleSet: true,
31 | },
32 |
33 | compute(data): (RuleSet & { ruleParsed: Rule; actionsParsed: Array }) | undefined {
34 | if (data.castRuleSet) {
35 | const ruleSet = JSON.parse(data.castRuleSet);
36 | ruleSet.ruleParsed = ruleSet.rule;
37 | ruleSet.actionsParsed = ruleSet.actions;
38 | return ruleSet;
39 | }
40 | },
41 | },
42 | exclusionRuleSetParsed: {
43 | needs: {
44 | exclusionRuleSet: true,
45 | },
46 | compute(data): (RuleSet & { ruleParsed: Rule; actionsParsed: Array }) | undefined {
47 | if (data.exclusionRuleSet) {
48 | const ruleSet = JSON.parse(data.exclusionRuleSet);
49 | ruleSet.ruleParsed = ruleSet.rule;
50 | ruleSet.actionsParsed = ruleSet.actions;
51 | return ruleSet;
52 | }
53 | },
54 | },
55 | excludeUsernamesParsed: {
56 | needs: {
57 | excludeUsernames: true,
58 | },
59 | compute(data): Array {
60 | return JSON.parse(data.excludeUsernames);
61 | },
62 | },
63 | framesParsed: {
64 | needs: {
65 | frames: true,
66 | },
67 | compute(data): {
68 | bgColor: string;
69 | } {
70 | const parsed = JSON.parse(data.frames);
71 | parsed.bgColor = parsed?.bgColor || "#ea580c";
72 | return parsed;
73 | },
74 | },
75 | },
76 | role: {
77 | permissionsParsed: {
78 | needs: {
79 | permissions: true,
80 | },
81 | compute(data): Array {
82 | return JSON.parse(data.permissions);
83 | },
84 | },
85 | },
86 | },
87 | })
88 | );
89 |
90 | export { db };
91 |
--------------------------------------------------------------------------------
/app/lib/farcaster-strategy.ts:
--------------------------------------------------------------------------------
1 | import { InviteCode, User } from "@prisma/client";
2 | import * as Sentry from "@sentry/remix";
3 | import { FarcasterUser } from "./auth.server";
4 | import { AuthenticateOptions, Strategy } from "remix-auth";
5 | import { SessionStorage } from "@remix-run/node";
6 | import { createAppClient, viemConnector } from "@farcaster/auth-kit";
7 | import { db } from "./db.server";
8 | import { getSharedEnv } from "./utils.server";
9 |
10 | export class FarcasterStrategy extends Strategy {
11 | name = "farcaster";
12 |
13 | async authenticate(
14 | request: Request,
15 | sessionStorage: SessionStorage,
16 | options: AuthenticateOptions
17 | ): Promise {
18 | const url = new URL(request.url);
19 | const credentials = Object.fromEntries(url.searchParams.entries());
20 |
21 | if (!credentials.message || !credentials.signature || !credentials.nonce) {
22 | return await this.failure("Missing message, signature or nonce", request, sessionStorage, options);
23 | }
24 |
25 | const env = getSharedEnv();
26 |
27 | const appClient = createAppClient({
28 | ethereum: viemConnector({
29 | rpcUrl: `https://optimism-mainnet.infura.io/v3/${env.infuraProjectId}`,
30 | }),
31 | });
32 |
33 | const verifyResponse = await appClient.verifySignInMessage({
34 | message: credentials.message,
35 | signature: credentials.signature as `0x${string}`,
36 | domain: new URL(env.hostUrl).host.split(":")[0],
37 | nonce: credentials.nonce,
38 | });
39 | // console.log("verifyResponse", JSON.stringify(verifyResponse, null, 2));
40 | const { success, fid, error } = verifyResponse;
41 |
42 | if (!success) {
43 | return await this.failure("Invalid signature", request, sessionStorage, options, error);
44 | }
45 |
46 | let user;
47 | try {
48 | user = await this.verify({
49 | fid: fid.toString(),
50 | username: credentials.username,
51 | pfpUrl: credentials.pfpUrl,
52 | request,
53 | });
54 | } catch (err) {
55 | console.error(err);
56 | Sentry.captureException(err, {
57 | extra: {
58 | fid,
59 | username: credentials.username,
60 | pfpUrl: credentials.pfpUrl,
61 | },
62 | });
63 |
64 | return await this.failure((err as Error).message, request, sessionStorage, options);
65 | }
66 |
67 | return this.success(user, request, sessionStorage, options);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/app/lib/god-strategy.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { User } from "@prisma/client";
3 | import { AuthenticateOptions, Strategy } from "remix-auth";
4 | import { SessionStorage } from "@remix-run/node";
5 | import { authenticator, getSession } from "./auth.server";
6 |
7 | export class GodStrategy extends Strategy {
8 | name = "god";
9 |
10 | async authenticate(
11 | request: Request,
12 | sessionStorage: SessionStorage,
13 | options: AuthenticateOptions
14 | ): Promise {
15 | const session = await getSession(request.headers.get("Cookie"));
16 | const username = session.get("impersonateAs");
17 |
18 | if (!username) {
19 | return await this.failure("not allowed", request, sessionStorage, options);
20 | }
21 |
22 | const user = await authenticator.isAuthenticated(request);
23 | if (!user) {
24 | return await this.failure("not authenticated", request, sessionStorage, options);
25 | }
26 |
27 | if (user.role !== "superadmin") {
28 | return await this.failure("unauthorized", request, sessionStorage, options);
29 | }
30 |
31 | const impersonatedUser = await this.verify({ username });
32 |
33 | if (!impersonatedUser) {
34 | return await this.failure(`${username} not found`, request, sessionStorage, options);
35 | }
36 |
37 | console.warn(`${user.name} is impersonating as ${impersonatedUser.name}`);
38 |
39 | return this.success(impersonatedUser, request, sessionStorage, options);
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/app/lib/http.server.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import axiosFactory, { AxiosError } from "axios";
3 |
4 | const retryDelay = 1000;
5 |
6 | export const http = axiosFactory.create({
7 | headers: {
8 | "x-agent": "modbot",
9 | },
10 | });
11 |
12 | http.interceptors.response.use(undefined, function axiosRetryInterceptor(err) {
13 | const config = err.config;
14 |
15 | console.error({
16 | status: err.response?.status,
17 | data: JSON.stringify(err.response?.data),
18 | });
19 |
20 | if (err.response?.status && (err.response.status === 429 || err.response.status >= 500) && !config.__retryCount) {
21 | config.__retryCount = 0;
22 | }
23 |
24 | if (config.__retryCount < 3) {
25 | // Max retry limit
26 | config.__retryCount += 1;
27 | const backoffDelay = getDelay(err, config.__retryCount);
28 | console.warn(`Received HTTP ${err.response.status}, retrying in ${backoffDelay}ms`);
29 |
30 | return new Promise((resolve) => {
31 | setTimeout(() => {
32 | resolve(http(config));
33 | }, backoffDelay);
34 | });
35 | }
36 |
37 | return Promise.reject(err);
38 | });
39 |
40 | function getDelay(err: AxiosError, retryCount: number) {
41 | if (err.response?.status === 429 && err.config?.url?.includes("neynar")) {
42 | return 2 ** retryCount * 30_000;
43 | }
44 |
45 | return 2 ** retryCount * retryDelay;
46 | }
47 |
--------------------------------------------------------------------------------
/app/lib/notifications.server.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { FullModeratedChannel } from "~/lib/types";
3 | import { db } from "./db.server";
4 | import axios, { AxiosError } from "axios";
5 |
6 | export type notifTypes = "usage";
7 |
8 | export async function sendNotification(props: {
9 | moderatedChannel: FullModeratedChannel;
10 | fid: string;
11 | nonce: string;
12 | type: notifTypes;
13 | message: string;
14 | }) {
15 | const { moderatedChannel, type, fid, nonce, message } = props;
16 | const alreadySent = await db.notification.findFirst({
17 | where: {
18 | userId: moderatedChannel.user.id,
19 | type,
20 | nonce,
21 | },
22 | });
23 |
24 | if (alreadySent) {
25 | console.log(`Already sent notification to ${fid}. Skipping.`);
26 | return;
27 | }
28 |
29 | try {
30 | await axios.put(
31 | "https://api.warpcast.com/v2/ext-send-direct-cast",
32 | {
33 | recipientFid: +fid,
34 | message,
35 | idempotencyKey: nonce,
36 | },
37 | {
38 | headers: {
39 | Authorization: `Bearer ${process.env.WARPCAST_DM_KEY}`,
40 | "Content-Type": "application/json",
41 | },
42 | }
43 | );
44 | } catch (e: any) {
45 | const err = e as AxiosError;
46 | if (err.response?.status === 403) {
47 | console.error(`Cannot send notification to ${fid} due to settings`);
48 | return;
49 | } else if (err.response?.status === 429) {
50 | console.error(`Rate limited sending notification to ${fid}. Giving up.`);
51 | return;
52 | } else if (err.response && err.response.status >= 400 && err.response.status <= 500) {
53 | console.log(`Likely double-send issue. Continuing.`);
54 | }
55 | }
56 |
57 | await db.notification.create({
58 | data: {
59 | userId: moderatedChannel.user.id,
60 | type,
61 | message,
62 | nonce,
63 | },
64 | });
65 | }
66 |
--------------------------------------------------------------------------------
/app/lib/otp-strategy.ts:
--------------------------------------------------------------------------------
1 | import type { SessionStorage } from '@remix-run/node'
2 | import type { AuthenticateOptions } from 'remix-auth'
3 | import { Strategy } from 'remix-auth'
4 | import type { User } from '@prisma/client'
5 |
6 | export type FarcasterUser = {
7 | fid: string
8 | username?: string
9 | pfpUrl?: string
10 | }
11 |
12 | export class OtpStrategy extends Strategy {
13 | name = 'otp'
14 |
15 | async authenticate(
16 | request: Request,
17 | sessionStorage: SessionStorage,
18 | options: AuthenticateOptions,
19 | ): Promise {
20 | const url = new URL(request.url)
21 | const code = url.searchParams.get('code')
22 |
23 | if (!code) {
24 | return await this.failure('Missing code', request, sessionStorage, options)
25 | }
26 |
27 | const user = await this.verify({ code })
28 |
29 | return this.success(user, request, sessionStorage, options)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/app/lib/permissions.server.ts:
--------------------------------------------------------------------------------
1 | import { actions } from "./cast-actions.server";
2 | import { actionToInstallLink } from "./utils";
3 | import { ActionType, actionDefinitions } from "./validations.server";
4 |
5 | export type Permission = {
6 | name: string;
7 | description: string;
8 | castActionInstallUrl?: string;
9 | id: string;
10 | };
11 |
12 | export function actionToPermission(action: ActionType): Permission["id"] {
13 | return `action:${action}`;
14 | }
15 |
16 | export const defaultPerms = [];
17 |
18 | export const permissionDefs = !actionDefinitions
19 | ? [...defaultPerms]
20 | : ([
21 | ...defaultPerms,
22 | {
23 | id: `action:ban`,
24 | name: actionDefinitions["ban"].friendlyName,
25 | description: actionDefinitions["ban"].description,
26 | castActionInstallUrl: actionToInstallLink(actions.find((a) => a.automodAction === "ban")!),
27 | },
28 | {
29 | id: `action:cooldown`,
30 | name: actionDefinitions["cooldown"].friendlyName,
31 | description: "Casts from this user will not be curated into Main for 24 hours.",
32 | castActionInstallUrl: actionToInstallLink(actions.find((a) => a.automodAction === "cooldown")!),
33 | },
34 | {
35 | id: `action:downvote`,
36 | name: actionDefinitions["downvote"].friendlyName,
37 | description: actionDefinitions["downvote"].description,
38 | castActionInstallUrl: actionToInstallLink(actions.find((a) => a.automodAction === "downvote")!),
39 | },
40 | {
41 | id: `action:like`,
42 | name: actionDefinitions["like"].friendlyName,
43 | description: actionDefinitions["like"].description,
44 | castActionInstallUrl: actionToInstallLink(actions.find((a) => a.automodAction === "like")!),
45 | },
46 | {
47 | id: `action:unlike`,
48 | name: actionDefinitions["unlike"].friendlyName,
49 | description: actionDefinitions["unlike"].description,
50 | castActionInstallUrl: actionToInstallLink(actions.find((a) => a.automodAction === "unlike")!),
51 | },
52 | ] as const satisfies Permission[]);
53 |
--------------------------------------------------------------------------------
/app/lib/simplehash.server.ts:
--------------------------------------------------------------------------------
1 | import { getSetCache } from "./utils.server";
2 | import { base, mainnet, optimism, zora } from "viem/chains";
3 | import { http } from "./http.server";
4 |
5 | export async function nftsByWallets(props: { chains: string[]; contractAddresses: string[]; wallets: string[] }) {
6 | const url = new URL(`https://preview.recaster.org/api/nft-proxy`);
7 | url.searchParams.set("chains", props.chains.join(","));
8 | url.searchParams.set("contract_addresses", props.contractAddresses.join(","));
9 | url.searchParams.set("wallet_addresses", props.wallets.join(","));
10 | url.searchParams.set("count", "1");
11 |
12 | const rsp = await http
13 | .get(url.toString(), {
14 | headers: {
15 | "X-API-KEY": process.env.SIMPLE_HASH_API_KEY!,
16 | },
17 | })
18 | .catch(() => {
19 | console.error("Failed to fetch nftsByWallets", url.toString());
20 | });
21 |
22 | return rsp?.data || {};
23 | }
24 |
25 | export function chainIdToChainName(props: { chainId: string }) {
26 | const mapping: Map = new Map([
27 | [String(zora.id), "zora"],
28 | [String(base.id), "base"],
29 | [String(optimism.id), "optimism"],
30 | [String(mainnet.id), "ethereum"],
31 | ]);
32 |
33 | return mapping.get(props.chainId);
34 | }
35 |
--------------------------------------------------------------------------------
/app/lib/singleton.server.ts:
--------------------------------------------------------------------------------
1 | // Borrowed & modified from https://github.com/jenseng/abuse-the-platform/blob/main/app/utils/singleton.ts
2 | // Thanks @jenseng!
3 |
4 | export const singleton = (
5 | name: string,
6 | valueFactory: () => Value
7 | ): Value => {
8 | const g = global as any;
9 | g.__singletons ??= {};
10 | g.__singletons[name] ??= valueFactory();
11 | return g.__singletons[name];
12 | };
13 |
--------------------------------------------------------------------------------
/app/lib/stats.server.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { db } from "./db.server";
3 | import { getSetCache } from "./utils.server";
4 |
5 | export type ModerationStats30Days = {
6 | likes: number;
7 | hides: number;
8 | approvalRate: number;
9 | uniqueCasters: number;
10 | };
11 |
12 | export async function getModerationStats30Days({ channelId }: { channelId: string }) {
13 | const cacheKey = `moderationStats30Days:${channelId}`;
14 | const logs = await db.moderationLog.findMany({
15 | where: {
16 | channelId: channelId,
17 | createdAt: {
18 | gte: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
19 | },
20 | },
21 | select: {
22 | affectedUserFid: true,
23 | action: true,
24 | },
25 | });
26 |
27 | const totalCount = new Set(logs.map((log) => log.affectedUserFid)).size;
28 | const likeCount = new Set(logs.filter((log) => log.action === "like").map((log) => log.affectedUserFid)).size;
29 |
30 | return {
31 | likes: likeCount,
32 | hides: totalCount - likeCount,
33 | approvalRate: totalCount === 0 ? 0 : likeCount / totalCount,
34 | uniqueCasters: likeCount,
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/app/lib/subscription.server.ts:
--------------------------------------------------------------------------------
1 | import { getAddress, getContract } from "viem";
2 | import { db } from "./db.server";
3 | import { base } from "viem/chains";
4 | import { hypersubAbi721, hypersubAbiV2 } from "./abis";
5 | import { neynar } from "./neynar.server";
6 | import { clientsByChainId } from "./viem.server";
7 | import { PlanType } from "./utils";
8 |
9 | export async function syncSubscriptions() {
10 | const activeUsers = await db.user.findMany({
11 | where: {
12 | plan: {
13 | not: "vip",
14 | },
15 | },
16 | });
17 |
18 | for (const user of activeUsers) {
19 | const plan = await refreshAccountStatus({ fid: user.id });
20 | console.log(
21 | `[subsync] ${user.name} plan: ${plan.plan}`
22 | );
23 | }
24 | }
25 |
26 | export async function getSubscriptionPlan(args: { fid: string; walletAddress?: string }): Promise<{
27 | plan: PlanType;
28 | tokenId: string | null;
29 | expiresAt: Date | null;
30 | }> {
31 | const client = clientsByChainId[base.id];
32 | const primeContractAddress = process.env.PRIME_CONTRACT_ADDRESS!;
33 | const hypersubV2ContractAddress = process.env.HYPERSUBV2_CONTRACT_ADDRESS!;
34 |
35 | const primeContract = getContract({
36 | address: getAddress(primeContractAddress),
37 | abi: hypersubAbi721,
38 | client,
39 | });
40 |
41 | const hypersubV2Contract = getContract({
42 | address: getAddress(hypersubV2ContractAddress),
43 | abi: hypersubAbiV2,
44 | client,
45 | });
46 |
47 | const rsp = await neynar.fetchBulkUsers([+args.fid]);
48 |
49 | if (rsp.users.length === 0) {
50 | throw new Error(`User not found: ${args.fid}`);
51 | }
52 |
53 | const user = rsp.users[0];
54 | const addresses = [args.walletAddress, ...user.verified_addresses.eth_addresses].filter(Boolean) as string[];
55 | for (const address of addresses) {
56 | const [primeSecondsRemaining, v2SecondsRemaining] = await Promise.all([
57 | primeContract.read.balanceOf([getAddress(address)]),
58 | hypersubV2Contract.read.balanceOf([getAddress(address)]),
59 | ]);
60 |
61 | if (v2SecondsRemaining > 0) {
62 | const subInfo = await hypersubV2Contract.read.subscriptionOf([getAddress(address)]);
63 |
64 | return {
65 | plan: subInfo.tierId === 1 ? "ultra" : "prime",
66 | tokenId: subInfo.tokenId.toString(),
67 | expiresAt: new Date(subInfo.expiresAt * 1000),
68 | };
69 | } else if (primeSecondsRemaining > 0) {
70 | const subInfo = await primeContract.read.subscriptionOf([getAddress(address)]);
71 | const tokenId = subInfo[0];
72 |
73 | return {
74 | plan: "prime",
75 | tokenId: tokenId.toString(),
76 | expiresAt: new Date(Date.now() + Number(primeSecondsRemaining * 1000n)),
77 | };
78 | }
79 | }
80 |
81 | return {
82 | plan: "basic",
83 | expiresAt: null,
84 | tokenId: null,
85 | };
86 | }
87 |
88 | export async function refreshAccountStatus(args: { fid: string }) {
89 | return {
90 | plan: "basic",
91 | expiresAt: null,
92 | tokenId: null,
93 | };
94 | // const user = await db.user.findFirst({
95 | // where: {
96 | // id: args.fid,
97 | // },
98 | // });
99 |
100 | // if (!user) {
101 | // return {
102 | // plan: "basic",
103 | // expiresAt: null,
104 | // tokenId: null,
105 | // };
106 | // }
107 |
108 | // if (user.plan === "vip") {
109 | // return {
110 | // plan: "vip",
111 | // tokenId: null,
112 | // expiresAt: null,
113 | // };
114 | // }
115 |
116 | // const plan = await getSubscriptionPlan({
117 | // fid: args.fid,
118 | // walletAddress: user.planWalletAddress ?? undefined,
119 | // });
120 |
121 | // await db.user.update({
122 | // where: {
123 | // id: args.fid,
124 | // },
125 | // data: {
126 | // plan: plan.plan,
127 | // planExpiry: plan.expiresAt,
128 | // planTokenId: plan.tokenId,
129 | // },
130 | // });
131 |
132 | // return plan;
133 | }
134 |
--------------------------------------------------------------------------------
/app/lib/viem.server.ts:
--------------------------------------------------------------------------------
1 | import { createPublicClient, defineChain, fallback, http } from "viem";
2 | import { arbitrum, base, mainnet, zora, optimism, polygon } from "viem/chains";
3 |
4 | const mainnetClient = createPublicClient({
5 | chain: mainnet,
6 | transport: fallback(
7 | [
8 | http(`https://mainnet.infura.io/v3/${process.env.INFURA_PROJECT_ID}`),
9 | http(`https://eth-mainnet.g.alchemy.com/v2/${process.env.ALCHEMY_API_KEY}`),
10 | ],
11 | {
12 | retryCount: 3,
13 | retryDelay: 2000,
14 | }
15 | ),
16 | });
17 |
18 | const optimismClient = createPublicClient({
19 | chain: optimism,
20 | transport: http(`https://optimism-mainnet.infura.io/v3/${process.env.INFURA_PROJECT_ID}`),
21 | });
22 |
23 | export const hamChain = defineChain({
24 | id: 5112,
25 | name: "Ham",
26 | nativeCurrency: {
27 | decimals: 18,
28 | name: "Ether",
29 | symbol: "ETH",
30 | },
31 | rpcUrls: {
32 | default: {
33 | http: ["https://rpc.ham.fun/"],
34 | },
35 | },
36 | blockExplorers: {
37 | default: { name: "Explorer", url: "https://explorer.ham.fun" },
38 | },
39 | });
40 |
41 | const hamClient = createPublicClient({
42 | chain: hamChain,
43 | transport: http(hamChain.rpcUrls.default.http[0]),
44 | });
45 |
46 | export const baseClient = createPublicClient({
47 | chain: base,
48 | transport: fallback(
49 | [
50 | http(process.env.BASE_RPC_URL!),
51 | http(process.env.BASE_RPC_URL2!),
52 | http(process.env.BASE_RPC_URL3!),
53 | http(process.env.BASE_RPC_URL4!),
54 | ],
55 | { retryCount: 5, retryDelay: 1000 }
56 | ),
57 | });
58 |
59 | const arbitrumClient = createPublicClient({
60 | chain: arbitrum,
61 | transport: http(`https://arbitrum-mainnet.infura.io/v3/${process.env.INFURA_PROJECT_ID}`),
62 | });
63 |
64 | const zoraClient = createPublicClient({
65 | chain: zora,
66 | transport: http(`https://rpc.zora.energy`),
67 | });
68 |
69 | const polygonClient = createPublicClient({
70 | chain: polygon,
71 | transport: http(`https://polygon-mainnet.infura.io/v3/${process.env.INFURA_PROJECT_ID}`),
72 | });
73 |
74 | export const clientsByChainId = {
75 | [String(mainnet.id)]: mainnetClient,
76 | [String(optimism.id)]: optimismClient,
77 | [String(base.id)]: baseClient,
78 | [String(arbitrum.id)]: arbitrumClient,
79 | [String(zora.id)]: zoraClient,
80 | [String(polygon.id)]: polygonClient,
81 | [String(hamChain.id)]: hamClient,
82 | };
83 |
84 | export const chainByChainId = {
85 | [String(mainnet.id)]: mainnet,
86 | [String(optimism.id)]: optimism,
87 | [String(base.id)]: base,
88 | [String(arbitrum.id)]: arbitrum,
89 | [String(zora.id)]: zora,
90 | [String(polygon.id)]: polygon,
91 | [String(hamChain.id)]: hamChain,
92 | };
93 |
--------------------------------------------------------------------------------
/app/root.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 |
6 | @layer base {
7 | :root {
8 | --background: 0 0% 100%;
9 | --foreground: 20 14.3% 4.1%;
10 | --card: 0 0% 100%;
11 | --card-foreground: 20 14.3% 4.1%;
12 | --popover: 0 0% 100%;
13 | --popover-foreground: 20 14.3% 4.1%;
14 | --primary: 24.6 95% 53.1%;
15 | --primary-foreground: 60 9.1% 97.8%;
16 | --secondary: 60 4.8% 95.9%;
17 | --secondary-foreground: 24 9.8% 10%;
18 | --muted: 60 4.8% 95.9%;
19 | --muted-foreground: 25 5.3% 44.7%;
20 | --accent: 60 4.8% 95.9%;
21 | --accent-foreground: 24 9.8% 10%;
22 | --destructive: 0 84.2% 60.2%;
23 | --destructive-foreground: 60 9.1% 97.8%;
24 | --border: 20 5.9% 90%;
25 | --input: 20 5.9% 90%;
26 | --ring: 24.6 95% 53.1%;
27 | --radius: 0.4rem;
28 | }
29 |
30 | .dark {
31 | --background: 20 14.3% 4.1%;
32 | --foreground: 60 9.1% 97.8%;
33 | --card: 20 14.3% 4.1%;
34 | --card-foreground: 60 9.1% 97.8%;
35 | --popover: 20 14.3% 4.1%;
36 | --popover-foreground: 60 9.1% 97.8%;
37 | --primary: 20.5 90.2% 48.2%;
38 | --primary-foreground: 60 9.1% 97.8%;
39 | --secondary: 12 6.5% 15.1%;
40 | --secondary-foreground: 60 9.1% 97.8%;
41 | --muted: 12 6.5% 15.1%;
42 | --muted-foreground: 24 5.4% 63.9%;
43 | --accent: 12 6.5% 15.1%;
44 | --accent-foreground: 60 9.1% 97.8%;
45 | --destructive: 0 72.2% 50.6%;
46 | --destructive-foreground: 60 9.1% 97.8%;
47 | --border: 12 6.5% 15.1%;
48 | --input: 12 6.5% 15.1%;
49 | --ring: 20.5 90.2% 48.2%;
50 | }
51 | }
52 |
53 |
54 | @layer base {
55 | * {
56 | @apply border-border;
57 | }
58 | body {
59 | @apply bg-background text-foreground;
60 | }
61 |
62 | .logo {
63 | @apply text-primary;
64 |
65 | font-optical-sizing: auto;
66 | font-weight: 700;
67 | font-style: normal;
68 | letter-spacing: -0.07em;
69 | }
70 |
71 | h1 {
72 | @apply text-foreground text-2xl font-bold;
73 | }
74 |
75 | h2 {
76 | @apply text-foreground text-xl font-bold;
77 | }
78 |
79 | h3 {
80 | @apply text-foreground text-lg font-semibold;
81 | }
82 |
83 | a {
84 | @apply text-primary underline;
85 | }
86 |
87 | #fc-btn-wrap > div > button {
88 | all: unset;
89 | @apply border border-zinc-200 bg-white shadow-sm hover:bg-zinc-100 hover:text-zinc-900 dark:border-zinc-800 dark:bg-zinc-950 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-950 disabled:pointer-events-none disabled:opacity-50 dark:focus-visible:ring-zinc-300 px-2 py-2
90 | w-[200px] sm:w-[150px] bg-transparent text-transparent hover:bg-transparent hover:text-transparent
91 | }
92 |
93 | #fc-btn-wrap div > button > span, #fc-btn-wrap .fc-authkit-signin-button > button > svg {
94 |
95 | visibility: hidden;
96 | }
97 |
98 | .glow-border {
99 | box-shadow: 0 0 5px rgba(0, 123, 255, 0.5), 0 0 10px rgba(0, 123, 255, 0.3);
100 | transition: box-shadow 0.3s ease-in-out;
101 | }
102 |
103 | .glow-border:hover {
104 | box-shadow: 0 0 10px rgba(0, 123, 255, 0.8), 0 0 20px rgba(0, 123, 255, 0.6);
105 | }
106 | }
--------------------------------------------------------------------------------
/app/routes/api.channels.$id.toggleEnable.tsx:
--------------------------------------------------------------------------------
1 | import { db } from "~/lib/db.server";
2 | import { registerWebhook, unregisterWebhook } from "~/lib/neynar.server";
3 |
4 | export async function toggleWebhook(args: { channelId: string; active: boolean }) {
5 | const { channelId, active } = args;
6 | const channel = await db.moderatedChannel.findUniqueOrThrow({
7 | where: {
8 | id: channelId,
9 | },
10 | });
11 |
12 | if (active) {
13 | registerWebhook({ rootParentUrl: channel.url! }).catch(console.error);
14 | } else {
15 | unregisterWebhook({ rootParentUrl: channel.url! }).catch(console.error);
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/app/routes/api.channels.$id.tsx:
--------------------------------------------------------------------------------
1 | import { json, LoaderFunctionArgs } from "@remix-run/node";
2 | import invariant from "tiny-invariant";
3 | import { db } from "~/lib/db.server";
4 | import { filterUserRules } from "./api.channels";
5 |
6 | export async function loader({ request, params }: LoaderFunctionArgs) {
7 | invariant(params.id, "id is required");
8 |
9 | const channel = await db.moderatedChannel.findUnique({
10 | where: {
11 | id: params.id,
12 | },
13 | });
14 |
15 | if (!channel) {
16 | return json({ error: "Channel not found" }, { status: 404 });
17 | }
18 |
19 | const {
20 | id,
21 | createdAt,
22 | updatedAt,
23 | active,
24 | imageUrl,
25 | url,
26 | feedType,
27 | userId,
28 | excludeCohosts,
29 | excludeUsernamesParsed,
30 | inclusionRuleSetParsed,
31 | exclusionRuleSetParsed,
32 | } = channel;
33 |
34 | return json({
35 | id,
36 | createdAt,
37 | updatedAt,
38 | active,
39 | imageUrl,
40 | url,
41 | feedType,
42 | userId,
43 | excludeCohosts,
44 | excludeUsers: excludeUsernamesParsed,
45 | membershipRequirements: filterUserRules(inclusionRuleSetParsed?.ruleParsed),
46 | inclusionRuleSet: inclusionRuleSetParsed?.ruleParsed,
47 | exclusionRuleSet: exclusionRuleSetParsed?.ruleParsed,
48 | });
49 | }
50 |
--------------------------------------------------------------------------------
/app/routes/api.channels.tsx:
--------------------------------------------------------------------------------
1 | import { json, LoaderFunctionArgs } from "@remix-run/node";
2 | import { db } from "~/lib/db.server";
3 | import { ruleDefinitions } from "~/lib/validations.server";
4 | import { Rule } from "~/rules/rules.type";
5 |
6 | export async function loader({ request, params }: LoaderFunctionArgs) {
7 | const channel = await db.moderatedChannel.findMany({});
8 |
9 | const channels = channel.map((channel) => {
10 | const {
11 | id,
12 | createdAt,
13 | updatedAt,
14 | active,
15 | imageUrl,
16 | url,
17 | feedType,
18 | userId,
19 | excludeCohosts,
20 | excludeUsernamesParsed,
21 | inclusionRuleSetParsed,
22 | exclusionRuleSetParsed,
23 | } = channel;
24 |
25 | return {
26 | id,
27 | createdAt,
28 | updatedAt,
29 | active,
30 | imageUrl,
31 | url,
32 | feedType,
33 | userId,
34 | excludeCohosts,
35 | excludeUsers: excludeUsernamesParsed,
36 | membershipRequirements: filterUserRules(inclusionRuleSetParsed?.ruleParsed),
37 | inclusionRuleSet: inclusionRuleSetParsed?.ruleParsed,
38 | exclusionRuleSet: exclusionRuleSetParsed?.ruleParsed,
39 | };
40 | });
41 |
42 | return json({
43 | results: channels,
44 | meta: {
45 | total: channels.length,
46 | },
47 | });
48 | }
49 |
50 | export function filterUserRules(rule: Rule | undefined) {
51 | if (!rule || !rule.conditions) {
52 | return rule;
53 | }
54 |
55 | const userScopedRules: Rule[] = [];
56 |
57 | for (const cond of rule.conditions) {
58 | const ruleDef = ruleDefinitions[cond.name];
59 | if (ruleDef.checkType === "user") {
60 | userScopedRules.push(cond);
61 | }
62 | }
63 |
64 | return {
65 | ...rule,
66 | conditions: userScopedRules,
67 | };
68 | }
69 |
--------------------------------------------------------------------------------
/app/routes/api.images.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import { CSSProperties } from "react";
3 | import satori from "satori";
4 | import { getChannelImageUrl } from "~/lib/utils";
5 | import { convertSvgToPngBase64, getSharedEnv } from "~/lib/utils.server";
6 |
7 | export async function loader({ request, params }: LoaderFunctionArgs) {
8 | const url = new URL(request.url);
9 | const message = url.searchParams.get("message") || "Hello";
10 | const channel = url.searchParams.get("channel") || "";
11 | const color = url.searchParams.get("c") || "ea580c";
12 | const imageBase64 = await generateFrame({ message, channel, color });
13 | const base64Data = imageBase64.split(",")[1];
14 | const imageBuffer = Buffer.from(base64Data, "base64");
15 |
16 | // Return the image as a response
17 | return new Response(imageBuffer, {
18 | headers: {
19 | "Content-Type": "image/png",
20 | "Cache-Control": "public, max-age=86400",
21 | },
22 | });
23 | }
24 |
25 | export async function generateFrame(props: { message: string; channel?: string; color?: string }) {
26 | const response = await fetch(`${getSharedEnv().hostUrl}/fonts/kode-mono-bold.ttf`);
27 | const fontBuffer = await response.arrayBuffer();
28 | const styles: CSSProperties = {
29 | display: "flex",
30 | color: "white",
31 | fontFamily: "Kode Mono",
32 | backgroundColor: props.color ? `#${props.color}` : "#ea580c", // #000 #472B82 #7c65c1
33 | height: "100%",
34 | width: "100%",
35 | paddingTop: 72,
36 | paddingBottom: 72,
37 | paddingLeft: 20,
38 | paddingRight: 20,
39 | flexDirection: "column",
40 | alignItems: "center",
41 | justifyContent: "center",
42 | textAlign: "center",
43 | fontSize: 38,
44 | fontWeight: 600,
45 | };
46 |
47 | const svg = await satori(
48 |
49 | {props.channel && (
50 | })
59 | )}
60 | {props.message}
61 | ,
62 | {
63 | width: 800,
64 | height: 418,
65 | fonts: [
66 | {
67 | name: "Kode Mono",
68 | data: fontBuffer,
69 | style: "normal",
70 | },
71 | ],
72 | }
73 | );
74 |
75 | return convertSvgToPngBase64(svg);
76 | }
77 |
--------------------------------------------------------------------------------
/app/routes/api.partners.channels.$id.activity.tsx:
--------------------------------------------------------------------------------
1 | import { json, LoaderFunctionArgs } from "@remix-run/node";
2 | import invariant from "tiny-invariant";
3 | import { z } from "zod";
4 | import { db } from "~/lib/db.server";
5 | import { formatZodError, getSharedEnv, requirePartnerApiKey } from "~/lib/utils.server";
6 |
7 | const querySchema = z.object({
8 | page: z.coerce.number().int().positive().default(1),
9 | limit: z.coerce.number().int().positive().max(2000).default(20),
10 | actions: z.array(z.string()).optional(),
11 | sortBy: z.enum(["createdAt"]).default("createdAt"),
12 | sortOrder: z.enum(["asc", "desc"]).default("desc"),
13 | });
14 |
15 | export async function loader({ request, params }: LoaderFunctionArgs) {
16 | await requirePartnerApiKey({ request });
17 |
18 | const { id } = params;
19 | invariant(id, "channel id required");
20 |
21 | const url = new URL(request.url);
22 | const queryParams = Object.fromEntries(url.searchParams);
23 |
24 | const result = querySchema.safeParse({
25 | ...queryParams,
26 | actions: queryParams.actions ? queryParams.actions.split(",") : undefined,
27 | });
28 |
29 | if (!result.success) {
30 | return json(
31 | {
32 | message: formatZodError(result.error),
33 | },
34 | {
35 | status: 400,
36 | }
37 | );
38 | }
39 |
40 | const { page, limit, actions, sortBy, sortOrder } = result.data;
41 | const baseQuery = {
42 | where: {
43 | channelId: id,
44 | ...(actions && actions.length > 0 ? { action: { in: actions } } : {}),
45 | },
46 | orderBy: { [sortBy]: sortOrder },
47 | take: limit,
48 | skip: (page - 1) * limit,
49 | };
50 |
51 | const [moderationLogs, total] = await Promise.all([
52 | db.moderationLog.findMany(baseQuery),
53 | db.moderationLog.count({ where: baseQuery.where }),
54 | ]);
55 |
56 | const totalPages = Math.ceil(total / limit);
57 | const next =
58 | page + 1 > totalPages
59 | ? null
60 | : `${getSharedEnv().hostUrl}/api/channels/${id}/activity?${new URLSearchParams({
61 | ...queryParams,
62 | page: String(Math.min(page + 1, totalPages)),
63 | })}`;
64 |
65 | return json({
66 | results: moderationLogs,
67 | meta: {
68 | page,
69 | limit,
70 | next,
71 | total,
72 | },
73 | });
74 | }
75 |
--------------------------------------------------------------------------------
/app/routes/api.partners.channels.$id.roles.$roleId.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import invariant from "tiny-invariant";
3 | import { z } from "zod";
4 | import { db } from "~/lib/db.server";
5 | import { requirePartnerApiKey, getSharedEnv } from "~/lib/utils.server";
6 | import { typedjson } from "remix-typedjson";
7 |
8 | const querySchema = z.object({
9 | page: z.coerce.number().int().positive().default(1),
10 | limit: z.coerce.number().int().positive().max(100).default(20),
11 | sortBy: z.enum(["username", "createdAt"]).default("username"),
12 | sortOrder: z.enum(["asc", "desc"]).default("asc"),
13 | });
14 |
15 | export async function loader({ request, params }: LoaderFunctionArgs) {
16 | await requirePartnerApiKey({ request });
17 | invariant(params.id, "id is required");
18 | invariant(params.roleId, "roleId is required");
19 |
20 | const url = new URL(request.url);
21 | const queryParams = Object.fromEntries(url.searchParams);
22 |
23 | const { page, limit, sortBy, sortOrder } = querySchema.parse(queryParams);
24 |
25 | const baseQuery = {
26 | where: {
27 | channelId: params.id,
28 | roleId: params.roleId,
29 | },
30 | orderBy: { [sortBy]: sortOrder },
31 | take: limit,
32 | skip: (page - 1) * limit,
33 | };
34 |
35 | const [delegates, total] = await Promise.all([
36 | db.delegate.findMany(baseQuery),
37 | db.delegate.count({ where: baseQuery.where }),
38 | ]);
39 |
40 | const totalPages = Math.ceil(total / limit);
41 | const next =
42 | page + 1 > totalPages
43 | ? null
44 | : `${getSharedEnv().hostUrl}/api/channels/${params.id}/roles/${
45 | params.roleId
46 | }/delegates?${new URLSearchParams({
47 | ...queryParams,
48 | page: String(Math.min(page + 1, totalPages)),
49 | })}`;
50 |
51 | return typedjson({
52 | results: delegates,
53 | meta: {
54 | page,
55 | limit,
56 | next,
57 | total,
58 | },
59 | });
60 | }
61 |
--------------------------------------------------------------------------------
/app/routes/api.partners.channels.$id.roles.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import invariant from "tiny-invariant";
3 | import { typedjson } from "remix-typedjson";
4 | import { requirePartnerApiKey } from "~/lib/utils.server";
5 | import { db } from "~/lib/db.server";
6 |
7 | export async function loader({ request, params }: LoaderFunctionArgs) {
8 | await requirePartnerApiKey({ request });
9 | invariant(params.id, "id is required");
10 |
11 | const results = await db.role.findMany({
12 | where: {
13 | channelId: params.id,
14 | },
15 | orderBy: {
16 | name: "asc",
17 | },
18 | });
19 |
20 | return typedjson({
21 | results,
22 | meta: {},
23 | });
24 | }
25 |
--------------------------------------------------------------------------------
/app/routes/api.searchFarcasterUser.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import { typedjson } from "remix-typedjson";
3 | import { neynar } from "~/lib/neynar.server";
4 | import { requireUser } from "~/lib/utils.server";
5 |
6 | export async function loader({ request }: LoaderFunctionArgs) {
7 | const url = new URL(request.url);
8 |
9 | const username = url.searchParams.get("username");
10 | if (!username) {
11 | return typedjson([]);
12 | }
13 |
14 | await requireUser({ request });
15 | const users = await neynar.searchUser(username);
16 | return typedjson(users.result.users);
17 | }
18 |
--------------------------------------------------------------------------------
/app/routes/api.searchMoxieMemberTokens.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import { typedjson } from "remix-typedjson";
3 | import { searchMemberFanTokens } from "~/lib/airstack.server";
4 | import { neynar } from "~/lib/neynar.server";
5 | import { requireUser } from "~/lib/utils.server";
6 |
7 | export async function loader({ request }: LoaderFunctionArgs) {
8 | const url = new URL(request.url);
9 |
10 | const username = url.searchParams.get("username");
11 | if (!username) {
12 | return typedjson([]);
13 | }
14 |
15 | await requireUser({ request });
16 | const res = await searchMemberFanTokens({ username });
17 | return typedjson(res);
18 | }
19 |
--------------------------------------------------------------------------------
/app/routes/api.signer.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import { redirect } from "remix-typedjson";
3 | import { db } from "~/lib/db.server";
4 | import { getUser } from "~/lib/neynar.server";
5 | import { getSharedEnv, requireSuperAdmin } from "~/lib/utils.server";
6 |
7 | export async function loader({ request }: LoaderFunctionArgs) {
8 | await requireSuperAdmin({ request });
9 |
10 | const url = new URL(request.url);
11 | const signerUuid = url.searchParams.get("signerUuid") ?? undefined;
12 | const fid = url.searchParams.get("fid") ?? undefined;
13 |
14 | if (!signerUuid || !fid) {
15 | return new Response("Missing signerUuid or fid", { status: 400 });
16 | }
17 |
18 | const user = await getUser({ fid });
19 |
20 | const signer = await db.signer.create({
21 | data: {
22 | signerUuid,
23 | username: user.username,
24 | fid: String(user.fid),
25 | avatarUrl: user.pfp_url || getSharedEnv().hostUrl + "/apple-touch-icon.png",
26 | },
27 | });
28 |
29 | return redirect(`/signer?id=${signer.id}`);
30 | }
31 |
32 | export default function Screen() {
33 | return (
34 |
35 | Whops! You should have already been redirected.
36 |
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/routes/api.transaction.$channel.tsx:
--------------------------------------------------------------------------------
1 | import { User } from "@neynar/nodejs-sdk/build/neynar-api/v2";
2 | import { ActionFunctionArgs, json } from "@remix-run/node";
3 | import invariant from "tiny-invariant";
4 | import { parseEther } from "viem";
5 | import { db } from "~/lib/db.server";
6 | import { parseMessage } from "~/lib/utils.server";
7 | export async function action({ request, params }: ActionFunctionArgs) {
8 | invariant(params.channel, "channel id is required");
9 | const channelId = params.channel;
10 | const data = await request.json();
11 | const message = await parseMessage(data);
12 | const user = message.action.interactor as User;
13 | const paymentAddress = data.untrustedData.address;
14 | // parse frames
15 | // get price and address from channel
16 | const channel = await db.moderatedChannel.findFirst({ where: { id: channelId } });
17 | if (!channel) {
18 | throw new Error("Channel not found");
19 | }
20 |
21 | const rules = channel.inclusionRuleSetParsed?.ruleParsed.conditions?.find(
22 | (rule) => rule.name === "membershipFeeRequired"
23 | );
24 | if (!rules) {
25 | throw new Error("Membership fee rule not found");
26 | }
27 | const { receiveAddress, feeAmount } = rules.args;
28 |
29 | const price = parseEther(feeAmount).toString();
30 | // create order
31 | const channelOrder = await db.channelOrder.create({
32 | data: {
33 | channelId,
34 | fid: user.fid.toString(),
35 | address: receiveAddress,
36 | },
37 | });
38 | const jsonData = { id: channelOrder.id };
39 | const hexData = ("0x" + Buffer.from(JSON.stringify(jsonData)).toString("hex")) as `0x${string}`;
40 | return json(
41 | {
42 | chainId: "eip155:8453",
43 | method: "eth_sendTransaction",
44 | params: {
45 | abi: [],
46 | value: price,
47 | to: receiveAddress,
48 | data: hexData,
49 | },
50 | },
51 | { status: 200 }
52 | );
53 | }
54 |
--------------------------------------------------------------------------------
/app/routes/api.warpcast.channels.$id.ts:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import axios from "axios";
3 | import { typedjson } from "remix-typedjson";
4 |
5 | export async function loader({ params }: LoaderFunctionArgs) {
6 | const channelId = params.id;
7 | if (!channelId) {
8 | return typedjson(
9 | {
10 | message: "Channel ID is required",
11 | },
12 | {
13 | status: 400,
14 | }
15 | );
16 | }
17 |
18 | // Need to proxy this due to CORS if fetched from browser
19 | const rsp = await axios.get(`https://api.warpcast.com/v1/channel?channelId=${channelId}`);
20 |
21 | return typedjson(rsp.data);
22 | }
23 |
--------------------------------------------------------------------------------
/app/routes/api.webhooks.alchemy.tsx:
--------------------------------------------------------------------------------
1 | import { ActionFunctionArgs, json } from "@remix-run/node";
2 | import { refreshAccountStatus } from "~/lib/subscription.server";
3 | import { neynar } from "~/lib/neynar.server";
4 |
5 | export async function action({ request }: ActionFunctionArgs) {
6 | const data: AlchemyWebhook = await request.json();
7 |
8 | console.log(JSON.stringify(data, null, 2));
9 |
10 | if (data.type !== "NFT_ACTIVITY") {
11 | return json({ message: "ok" }, { status: 200 });
12 | }
13 |
14 | if (data.event.fromAddress !== "0x0000000000000000000000000000000000000000") {
15 | return json({ message: "not a mint tx" }, { status: 200 });
16 | }
17 |
18 | const users = await neynar.fetchBulkUsersByEthereumAddress([data.event.toAddress]);
19 | const user = users[data.event.toAddress]?.[0];
20 |
21 | if (!user) {
22 | return json({ message: "user not found" }, { status: 200 });
23 | }
24 |
25 | const plan = await refreshAccountStatus({ fid: String(user.fid) });
26 |
27 | console.log(`refreshed plan for user ${user.fid}: ${plan.plan}`);
28 |
29 | return json({ message: "ok" }, { status: 200 });
30 | }
31 |
32 | export type AlchemyWebhook = {
33 | webhookId: string;
34 | id: string;
35 | createdAt: Date;
36 | type: string;
37 | event: Event;
38 | };
39 |
40 | export type Event = {
41 | fromAddress: string;
42 | toAddress: string;
43 | erc1155Metadata: Erc1155Metadatum[];
44 | category: string;
45 | log: Log;
46 | };
47 |
48 | export type Erc1155Metadatum = {
49 | tokenId: string;
50 | value: string;
51 | };
52 |
53 | export type Log = {
54 | address: string;
55 | topics: string[];
56 | data: string;
57 |
58 | blockNumber: string;
59 | transactionHash: string;
60 | transactionIndex: string;
61 | blockHash: string;
62 | logIndex: string;
63 | removed: boolean;
64 | };
65 |
--------------------------------------------------------------------------------
/app/routes/api.webhooks.dev.tsx:
--------------------------------------------------------------------------------
1 | import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/node";
2 | import { holdingFanTokenBalance } from "~/lib/airstack.server";
3 | import { webhookQueue } from "~/lib/bullish.server";
4 | import { validateCast } from "~/lib/cast-mod.server";
5 | import { db } from "~/lib/db.server";
6 | import { moderateCast } from "~/lib/warpcast.server";
7 |
8 | // import { webhookQueue } from "~/lib/bullish.server";
9 |
10 | export async function loader({ request }: LoaderFunctionArgs) {
11 | // webhookQueue.add(
12 | // "webhookQueue",
13 | // {
14 | // url: request.url,
15 | // },
16 | // {
17 | // removeOnComplete: true,
18 | // removeOnFail: 10_000,
19 | // }
20 | // );
21 | // await moderateCast({ hash: "0x35e4abdaf2a2f08cb6452f8ea86a29f116bb680f", action: "hide" });
22 | const balance = await holdingFanTokenBalance({ fid: 548932, symbol: "cid:wac" });
23 | console.log(balance);
24 | return json({
25 | message: "enqueued",
26 | });
27 | }
28 |
--------------------------------------------------------------------------------
/app/routes/api.webhooks.neynar.tsx:
--------------------------------------------------------------------------------
1 | import { ActionFunctionArgs, json } from "@remix-run/node";
2 | import { requireValidSignature } from "~/lib/utils.server";
3 |
4 | // import { webhookQueue } from "~/lib/bullish.server";
5 | import { WebhookCast } from "~/lib/types";
6 | import { isRuleTargetApplicable } from "~/lib/automod.server";
7 | import { db } from "~/lib/db.server";
8 | import { getWarpcastChannel, isChannelMember, moderateCast } from "~/lib/warpcast.server";
9 | import { validateCast } from "~/lib/cast-mod.server";
10 | import { unknown } from "zod";
11 | import { ModeratedChannel } from "@prisma/client";
12 | import { Rule } from "~/rules/rules.type";
13 |
14 | export async function action({ request }: ActionFunctionArgs) {
15 | const rawPayload = await request.text();
16 | const webhookNotif = JSON.parse(rawPayload) as {
17 | type: string;
18 | data: WebhookCast;
19 | };
20 |
21 | if (webhookNotif.type !== "cast.created") {
22 | return json({ message: "Invalid webhook type" }, { status: 400 });
23 | }
24 |
25 | await requireValidSignature({
26 | request,
27 | payload: rawPayload,
28 | sharedSecret: process.env.NEYNAR_WEBHOOK_SECRET!,
29 | incomingSignature: request.headers.get("X-Neynar-Signature")!,
30 | });
31 |
32 | const channelName = webhookNotif.data?.channel?.id;
33 |
34 | if (!channelName) {
35 | console.error(`Couldn't extract channel name: ${webhookNotif.data.root_parent_url}`, webhookNotif.data);
36 | return json({ message: "Invalid channel name" }, { status: 400 });
37 | }
38 |
39 | if (isRuleTargetApplicable("reply", webhookNotif.data)) {
40 | return json({ message: "Ignoring reply" });
41 | }
42 | const moderatedChannel = await db.moderatedChannel.findUnique({
43 | where: {
44 | id: channelName,
45 | },
46 | });
47 | if (!moderatedChannel) {
48 | return json({ message: "no moderated channel found" });
49 | }
50 | if (process.env.NODE_ENV === "development") {
51 | console.log(webhookNotif);
52 | }
53 | // check if channel member
54 | const authorFid = webhookNotif.data.author.fid;
55 | const isMember = await isChannelMember({ channel: channelName, fid: authorFid });
56 | if (!isMember) {
57 | return json({ message: "Ignoring cast from non-member" });
58 | }
59 | const wcChannel = await getWarpcastChannel({ channel: channelName });
60 | const channelModeratorFids = wcChannel.moderatorFids;
61 | const goodFids =
62 | channelModeratorFids.includes(authorFid) ||
63 | moderatedChannel.excludeUsernamesParsed.map((u) => u.value).includes(authorFid);
64 |
65 | let shouldHide = false;
66 | const rulesLength = moderatedChannel.castRuleSetParsed?.ruleParsed.conditions?.length || 0;
67 | const slowMode = moderatedChannel.slowModeHours > 0;
68 | if (!goodFids) {
69 | if (slowMode) {
70 | const count = await db.castLog.count({
71 | where: {
72 | channelId: channelName,
73 | authorFid,
74 | createdAt: {
75 | gte: Math.floor(new Date().getTime() / 1000) - 3600 * moderatedChannel.slowModeHours,
76 | },
77 | },
78 | });
79 | shouldHide = count >= 1;
80 | }
81 | if (!shouldHide && rulesLength > 0) {
82 | const result = await validateCast({
83 | moderatedChannel: moderatedChannel as unknown as ModeratedChannel & { castRuleSetParsed: { ruleParsed: Rule } },
84 | cast: webhookNotif.data,
85 | });
86 | shouldHide = !result.passedRule;
87 | }
88 | }
89 |
90 | await db.castLog.create({
91 | data: {
92 | hash: webhookNotif.data.hash,
93 | channelId: channelName,
94 | authorFid: webhookNotif.data.author.fid,
95 | data: JSON.stringify(webhookNotif.data),
96 | createdAt: Math.floor(new Date(webhookNotif.data.timestamp).getTime() / 1000),
97 | status: shouldHide ? 1 : 0,
98 | },
99 | });
100 | if (shouldHide && channelModeratorFids.includes(861203)) {
101 | console.log(`Hiding cast ${webhookNotif.data.hash} from ${channelName}`);
102 | await moderateCast({ hash: webhookNotif.data.hash, action: "hide" });
103 | }
104 |
105 | return json({
106 | message: "success",
107 | });
108 | }
109 |
--------------------------------------------------------------------------------
/app/routes/auth.farcaster.tsx:
--------------------------------------------------------------------------------
1 | import type { LoaderFunctionArgs } from "@remix-run/node";
2 | import { authenticator } from "~/lib/auth.server";
3 |
4 | export async function loader({ request }: LoaderFunctionArgs) {
5 | return await authenticator.authenticate("farcaster", request, {
6 | successRedirect: "/~",
7 | failureRedirect: "/login?error=no-access",
8 | });
9 | }
10 |
11 | export default function Screen() {
12 | return (
13 |
14 | Whops! You should have already been redirected.
15 |
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/app/routes/auth.god.tsx:
--------------------------------------------------------------------------------
1 | import { ActionFunctionArgs } from "@remix-run/node";
2 | import { Loader } from "lucide-react";
3 | import { authenticator } from "~/lib/auth.server";
4 | import { requireSuperAdmin } from "~/lib/utils.server";
5 |
6 | export async function loader({ request }: ActionFunctionArgs) {
7 | await requireSuperAdmin({ request });
8 |
9 | await authenticator.authenticate("god", request, {
10 | successRedirect: "/~",
11 | failureRedirect: "/login",
12 | });
13 | }
14 |
15 | export default function Screen() {
16 | return (
17 |
18 |
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/app/routes/channels.$channel.paid.tsx:
--------------------------------------------------------------------------------
1 | import type { ActionFunctionArgs } from "@remix-run/node";
2 | import { frameResponse, getSharedEnv } from "~/lib/utils.server";
3 | import invariant from "tiny-invariant";
4 | import { baseClient } from "~/lib/viem.server";
5 | import { db } from "~/lib/db.server";
6 | import { inviteToChannel } from "~/lib/warpcast.server";
7 |
8 | function getFrameImageUrl(props: { message: string; channel?: string; color?: string | null }) {
9 | const { message, channel, color } = props;
10 | return `${getSharedEnv().hostUrl}/api/images?message=${message}${channel ? `&channel=${channel}` : ""}${
11 | color ? `&c=${color}` : ""
12 | }`;
13 | }
14 |
15 | export async function action({ request, params }: ActionFunctionArgs) {
16 | invariant(params.channel, "channel id is required");
17 | const channelId = params.channel;
18 | const url = new URL(request.url);
19 | const color = url.searchParams.get("c");
20 | const transactionIdFromUrl = url.searchParams.get("transactionId") as `0x${string}` | undefined;
21 | const data = await request.json();
22 | const transactionId = (data.untrustedData?.transactionId as `0x${string}` | undefined) || transactionIdFromUrl;
23 | if (!transactionId) {
24 | return frameResponse({
25 | title: "Internal error",
26 | image: getFrameImageUrl({
27 | message: "Transaction ID is required",
28 | color,
29 | }),
30 | buttons: [
31 | {
32 | text: "Contact Dev",
33 | link: `https://warpcast.com/~/inbox/create/3346?text=${encodeURIComponent(
34 | `Transaction error when joining /${channelId}?`
35 | )}`,
36 | },
37 | ],
38 | });
39 | }
40 | try {
41 | const tx = await baseClient.getTransaction({ hash: transactionId });
42 | if (tx.input && tx.input !== "0x") {
43 | const decodedData = Buffer.from(tx.input.slice(2), "hex").toString();
44 | const jsonStartIndex = decodedData.indexOf("{");
45 | const jsonEndIndex = decodedData.lastIndexOf("}") + 1;
46 | const jsonData = decodedData.slice(jsonStartIndex, jsonEndIndex);
47 | const parsedData = JSON.parse(jsonData) as { id: string };
48 | const channelOrderId = parsedData.id;
49 | const channelOrder = await db.channelOrder.findUniqueOrThrow({
50 | where: { id: channelOrderId },
51 | });
52 | if (!channelOrder) {
53 | throw new Error("Channel order not found");
54 | }
55 | await db.channelOrder.update({
56 | where: { id: channelOrderId },
57 | data: { status: 1, txHash: transactionId },
58 | });
59 | await inviteToChannel({ channelId, fid: Number(channelOrder.fid) });
60 | await db.moderationLog.updateMany({
61 | where: {
62 | affectedUserFid: channelOrder.fid.toString(),
63 | channelId,
64 | action: "hideQuietly",
65 | reason: {
66 | contains: "Membership fee required",
67 | },
68 | },
69 | data: {
70 | action: "like",
71 | actor: "system",
72 | reason: `Membership fee paid`,
73 | },
74 | });
75 | return frameResponse({
76 | title: "Success",
77 | image: getFrameImageUrl({
78 | message: "Invite sent!",
79 | color,
80 | }),
81 | buttons: [
82 | {
83 | text: "Check Notifications",
84 | link: `https://warpcast.com/~/notifications/channel-role-invites?groupId=channels%21channel-role-invite%3Amember`,
85 | },
86 | ],
87 | });
88 | }
89 | } catch (e) {
90 | console.log("failed to get transaction");
91 | }
92 | return frameResponse({
93 | title: "Pending",
94 | image: "https://cdn.recaster.org/tx_loading.gif",
95 | buttons: [
96 | {
97 | text: "Refresh",
98 | postUrl: `${getSharedEnv().hostUrl}/channels/${channelId}/paid?c=${color}&transactionId=${transactionId}`,
99 | },
100 | ],
101 | });
102 | }
103 |
--------------------------------------------------------------------------------
/app/routes/channels._index.tsx:
--------------------------------------------------------------------------------
1 | import { MetaFunction } from "@remix-run/node";
2 |
3 | import { typedjson, useTypedLoaderData } from "remix-typedjson";
4 | import { db } from "~/lib/db.server";
5 | import { ChannelCard } from "./~._index";
6 | import { Link } from "@remix-run/react";
7 |
8 | export const meta: MetaFunction = () => [
9 | {
10 | title: "Farcaster Channels Managed by ModBot",
11 | },
12 | ];
13 | export async function loader() {
14 | const channels = await db.moderatedChannel.findMany({
15 | select: {
16 | id: true,
17 | imageUrl: true,
18 | },
19 | orderBy: {
20 | createdAt: "desc",
21 | },
22 | });
23 |
24 | return typedjson({ channels });
25 | }
26 |
27 | export default function Channels() {
28 | const { channels } = useTypedLoaderData();
29 |
30 | return (
31 |
32 |
33 | All Channels
34 |
35 |
36 | {channels.map((channel) => (
37 |
38 |
39 |
40 | ))}
41 |
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/app/routes/channels.tsx:
--------------------------------------------------------------------------------
1 | import { Link, Outlet, useParams } from "@remix-run/react";
2 | import { Button } from "~/components/ui/button";
3 | import { cn } from "~/lib/utils";
4 |
5 | export default function Screen() {
6 | const { id } = useParams();
7 | return (
8 |
9 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/app/routes/disclosure.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function Disclosure() {
4 | return (
5 |
6 | Commercial Disclosure (特定商取引法に基づく表記)
7 |
8 |
9 |
10 | Legal Name: John G
11 |
12 |
13 | Address: (We will disclose without delay if requested for sole proprietors)
14 |
15 |
16 | Phone Number: (We will disclose without delay if requested for sole proprietors)
17 |
18 |
19 | Operating Hours: 10:00 - 16:00 JST (excluding weekends and holidays)
20 |
21 |
22 | Email Address: admin@modbot.sh
23 |
24 |
25 | Head of Operations: John G
26 |
27 |
28 | Additional Fees
29 | No additional fees for digital services.
30 |
31 | Exchanges & Returns Policy
32 |
33 | - Before Shipping: N/A
34 |
35 |
36 | - After Shipping: N/A
37 |
38 |
39 | - Defective Goods and Services: In case of any issues with the service, please contact our
40 | support center at admin@modbot.sh. We will address the issue promptly, offering solutions such as service
41 | credits or adjustments as appropriate.
42 |
43 |
44 | Delivery Times
45 | Service activation is immediate upon subscription confirmation.
46 |
47 | Accepted Payment Methods
48 | Credit Cards
49 |
50 | Payment Period
51 | - Credit card payments are processed immediately.
52 |
53 | Price
54 | Plan depending on number of channels and usage fees. Charged per month (inclusive of all taxes)
55 |
56 | Optional Items
57 |
58 | - Application Period: Subscription available year-round.
59 |
60 |
61 | - Available Quantity: Unlimited.
62 |
63 |
64 | - Operating Environment: Accessible on any device with internet connectivity and a web
65 | browser.
66 |
67 |
68 |
69 | );
70 | }
71 |
--------------------------------------------------------------------------------
/app/routes/login.tsx:
--------------------------------------------------------------------------------
1 | import { AuthKitProvider, SignInButton, StatusAPIResponse } from "@farcaster/auth-kit";
2 | import { LoaderFunctionArgs } from "@remix-run/node";
3 | import { useNavigate } from "@remix-run/react";
4 | import { useCallback } from "react";
5 | import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
6 | import invariant from "tiny-invariant";
7 | import { Alert } from "~/components/ui/alert";
8 | import { authenticator, redirectCookie } from "~/lib/auth.server";
9 | import { getSharedEnv } from "~/lib/utils.server";
10 | import { LoginButton } from "./_index";
11 |
12 | export async function loader({ request }: LoaderFunctionArgs) {
13 | const url = new URL(request.url);
14 | const code = url.searchParams.get("code");
15 | const error = url.searchParams.get("error");
16 | const invite = url.searchParams.get("invite");
17 | const redirectTo = url.searchParams.get("redirectTo");
18 |
19 | if (code) {
20 | return await authenticator.authenticate("otp", request, {
21 | successRedirect: redirectTo || "/~",
22 | failureRedirect: "/login?error=invalid-otp",
23 | });
24 | }
25 |
26 | const user = await authenticator.isAuthenticated(request);
27 |
28 | if (user) {
29 | return redirect(redirectTo || "/~");
30 | }
31 |
32 | const headers = redirectTo ? { "Set-Cookie": await redirectCookie.serialize(redirectTo) } : undefined;
33 |
34 | return typedjson(
35 | {
36 | env: getSharedEnv(),
37 | invite,
38 | error,
39 | },
40 | {
41 | headers,
42 | }
43 | );
44 | }
45 |
46 | export default function Login() {
47 | const { env, error, invite } = useTypedLoaderData();
48 | const navigate = useNavigate();
49 |
50 | return (
51 |
58 |
59 | ModBot
60 |
61 | 25+ composable rules to automatically moderate channel content.
62 |
63 |
64 | {error && (
65 |
66 | {error}
67 |
68 | )}
69 |
70 |
71 |
72 |
73 |
74 |
75 | );
76 | }
77 |
--------------------------------------------------------------------------------
/app/routes/maintenance.tsx:
--------------------------------------------------------------------------------
1 | export default function Screen() {
2 | return (
3 |
10 | ModBot
11 | Under scheduled maintenance. Back shortly.
12 |
13 | );
14 | }
15 |
--------------------------------------------------------------------------------
/app/routes/privacy.tsx:
--------------------------------------------------------------------------------
1 | // app/routes/privacy-policy.tsx
2 | import { MetaFunction } from "@remix-run/node";
3 |
4 | export const meta: MetaFunction = () => [{ title: "Privacy Policy" }];
5 |
6 | const PrivacyPolicy = () => {
7 | return (
8 |
9 | Privacy Policy
10 |
11 |
12 | Introduction
13 |
14 | We respect the privacy of our users and are committed to protecting your personal information. This
15 | policy outlines our practices regarding data collection, use, and sharing.
16 |
17 |
18 |
19 |
20 | Data Collection
21 | We collect information necessary to provide our services, including:
22 |
23 | - Personal details (e.g., name, email address) when you sign up.
24 | - Payment information for processing transactions securely.
25 | - Technical data (e.g., IP address, browser type) for service improvement.
26 |
27 |
28 |
29 |
30 | Data Use
31 | We use your information to:
32 |
33 | - Provide and improve our services.
34 | - Process payments securely.
35 | - Communicate important service updates.
36 |
37 |
38 |
39 |
40 | Data Sharing
41 |
42 | We only share your information with third parties when necessary for service provision or legal
43 | requirements, including:
44 |
45 |
46 | - Payment processors, like Stripe, for transaction purposes.
47 | - Law enforcement, if required by law or to protect our rights.
48 |
49 |
50 |
51 |
52 | Your Rights
53 | You have rights regarding your data, including:
54 |
55 | - Accessing and updating your information.
56 | - Requesting data deletion, subject to certain exceptions.
57 |
58 | To exercise your rights, please contact us directly.
59 |
60 |
61 |
62 | Security
63 |
64 | We implement security measures to protect your data, but no system is entirely secure. We encourage
65 | users to safeguard their information.
66 |
67 |
68 |
69 |
70 | Changes to This Policy
71 |
72 | We may update this policy and will notify users of significant changes. We encourage you to review
73 | this policy periodically.
74 |
75 |
76 |
77 |
78 | Contact Us
79 |
80 | If you have questions or concerns about our privacy practices, please contact us at
81 | admin@modbot.sh.
82 |
83 |
84 |
85 | );
86 | };
87 |
88 | export default PrivacyPolicy;
89 |
--------------------------------------------------------------------------------
/app/routes/signer.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { useLoaderData, useNavigate } from "@remix-run/react";
3 | import { Loader } from "lucide-react";
4 | import { useState, useEffect } from "react";
5 | import { ClientOnly } from "remix-utils/client-only";
6 | import { Button } from "~/components/ui/button";
7 | import { getSharedEnv } from "~/lib/utils.server";
8 |
9 | export async function loader() {
10 | return {
11 | env: getSharedEnv(),
12 | };
13 | }
14 |
15 | export default function Screen() {
16 | const { env } = useLoaderData();
17 | const navigate = useNavigate();
18 | const [loggingIn, setLoggingIn] = useState(false);
19 |
20 | const onSuccess = (data: { signer_uuid: string; fid: string }) => {
21 | setLoggingIn(true);
22 |
23 | const params = new URLSearchParams();
24 | params.append("signerUuid", data.signer_uuid);
25 | params.append("fid", data.fid);
26 |
27 | navigate(`/api/signer?${params}`, {
28 | replace: true,
29 | });
30 | };
31 |
32 | useEffect(() => {
33 | function appendButton() {
34 | let script = document.getElementById("siwn-script") as HTMLScriptElement | null;
35 |
36 | if (!script) {
37 | script = document.createElement("script");
38 | script.id = "siwn-script";
39 | document.body.appendChild(script);
40 | }
41 |
42 | script.src = "https://neynarxyz.github.io/siwn/raw/1.2.0/index.js";
43 | script.async = true;
44 | script.defer = true;
45 |
46 | document.body.appendChild(script);
47 | }
48 |
49 | function bindSignInSuccess() {
50 | const win = window as any;
51 |
52 | if (!win._onSignInSuccess) {
53 | win._onSignInSuccess = onSuccess;
54 | }
55 | }
56 |
57 | appendButton();
58 | bindSignInSuccess();
59 | }, []);
60 |
61 | return (
62 |
63 | Loading...}>
64 | {() => {
65 | return (
66 | <>
67 | {loggingIn ? (
68 |
71 | ) : (
72 | setLoggingIn(true)}
74 | className="neynar_signin"
75 | data-theme="dark"
76 | data-styles='{ "font-size": "16px", "font-weight": "bold" }'
77 | data-client_id={env.neynarClientId}
78 | data-success-callback="_onSignInSuccess"
79 | />
80 | )}
81 | >
82 | );
83 | }}
84 |
85 |
86 | );
87 | }
88 |
--------------------------------------------------------------------------------
/app/routes/tos.tsx:
--------------------------------------------------------------------------------
1 | // app/routes/terms-of-service.tsx
2 | import { MetaFunction } from "@remix-run/node";
3 |
4 | export const meta: MetaFunction = () => [
5 | {
6 | title: "Terms of Service",
7 | },
8 | ];
9 |
10 | const TermsOfService = () => {
11 | return (
12 |
13 | Terms of Service
14 |
15 |
16 | Welcome!
17 |
18 | Thank you for choosing our services. These terms govern your use of our services and products,
19 | aiming to ensure a positive and constructive environment for all users.
20 |
21 |
22 |
23 |
24 | Using Our Services
25 | By using our services, you agree to:
26 |
27 | - Use them for lawful purposes and in a non-harmful manner to others.
28 | - Respect the intellectual property rights of the content we provide.
29 | - Not to misuse any part of our services, including unauthorized access or alterations.
30 |
31 |
32 |
33 |
34 | Your Account
35 |
36 | Some services may require an account. Keep your account information secure and notify us immediately
37 | of any unauthorized use.
38 |
39 |
40 |
41 |
42 | Content on Our Services
43 | Content you provide remains yours.
44 |
45 |
46 |
47 | Our Rights
48 |
49 | We reserve the right to modify or terminate services for any reason, without notice, at our
50 | discretion. We also reserve the right to remove content that we determine to be unlawful or
51 | offensive.
52 |
53 |
54 |
55 |
56 | Disclaimers
57 |
58 | Our services are provided "as is." We make no warranties regarding their reliability, availability,
59 | or ability to meet your needs. We disclaim all warranties to the extent permitted by law.
60 |
61 |
62 |
63 |
64 | Liability
65 |
66 | To the extent permitted by law, we shall not be liable for any indirect, incidental, special,
67 | consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or
68 | indirectly.
69 |
70 |
71 |
72 |
73 | Modifications to Terms
74 |
75 | We may modify these terms or any additional terms that apply to a service. You should look at the
76 | terms regularly. We’ll post notice of modifications to these terms on this page.
77 |
78 |
79 |
80 |
81 | Contact Us
82 |
83 | If you have any questions or concerns about these terms, please contact us at admin@modbot.sh.
84 |
85 |
86 |
87 | );
88 | };
89 |
90 | export default TermsOfService;
91 |
--------------------------------------------------------------------------------
/app/routes/~.channels.$id.roles_.$role.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unescaped-entities */
2 | import humanNumber from "human-number";
3 | import { LoaderFunctionArgs } from "@remix-run/node";
4 | import { Link, NavLink, Outlet } from "@remix-run/react";
5 | import { ArrowLeft } from "lucide-react";
6 | import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
7 | import invariant from "tiny-invariant";
8 | import { db } from "~/lib/db.server";
9 | import { cn } from "~/lib/utils";
10 | import { requireUser, requireUserCanModerateChannel } from "~/lib/utils.server";
11 |
12 | export async function loader({ request, params }: LoaderFunctionArgs) {
13 | invariant(params.id, "id is required");
14 | invariant(params.role, "role is required");
15 |
16 | const user = await requireUser({ request });
17 | const channel = await requireUserCanModerateChannel({
18 | userId: user.id,
19 | channelId: params.id,
20 | });
21 |
22 | const role = await db.role.findFirst({
23 | where: {
24 | channelId: channel.id,
25 | name: params.role,
26 | },
27 | include: {
28 | delegates: true,
29 | },
30 | });
31 |
32 | if (!role) {
33 | throw redirect("/404");
34 | }
35 |
36 | return typedjson({
37 | user,
38 | channel,
39 | role,
40 | });
41 | }
42 |
43 | export default function Screen() {
44 | const { channel, role } = useTypedLoaderData();
45 |
46 | return (
47 |
48 |
49 |
53 | ROLES
54 |
55 |
56 |
62 | {role.name}
63 |
64 |
65 |
66 | {!role.isEveryoneRole && (
67 |
68 |
72 | cn(
73 | isActive || isPending ? " bg-white text-black" : "text-gray-400",
74 | isPending ? "animate-pulse" : "",
75 | "w-full no-underline justify-start px-3 py-1 rounded-lg font-medium text-sm"
76 | )
77 | }
78 | to={`/~/channels/${channel.id}/roles/${role.name}`}
79 | >
80 | Permissions
81 |
82 |
86 | cn(
87 | isActive || isPending ? " bg-white text-black" : "text-gray-400",
88 | isPending ? "animate-pulse" : "",
89 | "w-full no-underline justify-start px-3 py-1 rounded-lg font-medium text-sm"
90 | )
91 | }
92 | to={`/~/channels/${channel.id}/roles/${role.name}/users`}
93 | >
94 | Users {role.delegates.length ? `(${humanNumber(role.delegates.length)})` : ""}
95 |
96 |
97 | )}
98 |
99 |
100 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/app/routes/~.channels.new.2.tsx:
--------------------------------------------------------------------------------
1 | import { LoaderFunctionArgs } from "@remix-run/node";
2 | import { Form, useNavigate } from "@remix-run/react";
3 | import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
4 | import { requireUser } from "~/lib/utils.server";
5 | import { getWarpcastChannel } from "~/lib/warpcast.server";
6 | import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
7 | import { FieldLabel } from "~/components/ui/fields";
8 | import { Button } from "~/components/ui/button";
9 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
10 | import { ChannelHeader } from "./~.channels.new.3";
11 |
12 | export async function loader({ request }: LoaderFunctionArgs) {
13 | const user = await requireUser({ request });
14 | const url = new URL(request.url);
15 |
16 | if (!url.searchParams.get("channelId")) {
17 | throw redirect("/~/new/1");
18 | }
19 |
20 | const channel = await getWarpcastChannel({ channel: url.searchParams.get("channelId")! });
21 |
22 | return typedjson({
23 | user,
24 | channel,
25 | });
26 | }
27 |
28 | export default function Screen() {
29 | const { channel } = useTypedLoaderData();
30 | const navigate = useNavigate();
31 |
32 | return (
33 |
34 |
35 |
36 | Who can join your channel?
37 |
38 | All options can be mixed with manual moderation directly in feed.
39 |
40 |
41 |
89 |
90 | );
91 | }
92 |
--------------------------------------------------------------------------------
/app/routes/~.channels.new.5.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | import { LoaderFunctionArgs } from "@remix-run/node";
3 | import { Link } from "@remix-run/react";
4 | import { typedjson, useTypedLoaderData } from "remix-typedjson";
5 | import { requireUser } from "~/lib/utils.server";
6 | import { Button } from "~/components/ui/button";
7 | import { db } from "~/lib/db.server";
8 | import { ArrowUpRight } from "lucide-react";
9 | import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "~/components/ui/card";
10 | import { banAction, cooldown24Action, likeAction, unlikeAction } from "~/lib/cast-actions.server";
11 | import { actionToInstallLink } from "~/lib/utils";
12 | import { ChannelHeader } from "./~.channels.new.3";
13 |
14 | export async function loader({ request, params }: LoaderFunctionArgs) {
15 | const user = await requireUser({ request });
16 | const url = new URL(request.url);
17 | const channelId = url.searchParams.get("channelId")!;
18 | const channel = await db.moderatedChannel.findUniqueOrThrow({
19 | where: {
20 | id: channelId,
21 | },
22 | });
23 |
24 | const castActions = [banAction, likeAction, unlikeAction, cooldown24Action];
25 |
26 | return typedjson({
27 | user,
28 | channel,
29 | castActions,
30 | });
31 | }
32 |
33 | export default function Screen() {
34 | const { channel, castActions } = useTypedLoaderData();
35 | const dst =
36 | channel.feedType === "custom"
37 | ? `/~/channels/${channel.id}/edit?onboarding=true`
38 | : `/~/channels/${channel.id}`;
39 |
40 | return (
41 |
42 |
43 |
44 |
45 | Install cast actions to moderate your channel
46 |
47 | Use cast actions to help you decide if a user can be invited to your
48 | channel. Coming soon.
49 |
50 |
51 | {/*
52 |
53 | {castActions.map((ca) => (
54 |
58 |
59 | {ca.name}
60 | {ca.description}
61 |
62 |
72 |
73 | ))}
74 |
75 | */}
76 |
77 |
82 |
83 |
84 |
85 | );
86 | }
87 |
--------------------------------------------------------------------------------
/app/routes/~.channels.new.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "@remix-run/react";
2 |
3 | export default function Screen() {
4 | return (
5 |
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/app/routes/~.channels.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-unescaped-entities */
2 | import { Outlet } from "@remix-run/react";
3 |
4 | export default function Channels() {
5 | return ;
6 | }
7 |
--------------------------------------------------------------------------------
/app/routes/~.logout.tsx:
--------------------------------------------------------------------------------
1 | import { ActionFunctionArgs } from "@remix-run/node";
2 | import { authenticator } from "~/lib/auth.server";
3 |
4 | export async function action({ request }: ActionFunctionArgs) {
5 | return await authenticator.logout(request, {
6 | redirectTo: "/",
7 | });
8 | }
9 |
10 | export default function Screen() {
11 | return (
12 |
13 | Whops! You should have already been redirected.
14 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/app/rules/airstack.ts:
--------------------------------------------------------------------------------
1 | import { farRank } from "~/lib/airstack.server";
2 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
3 |
4 | async function airstackSocialCapitalRank(args: CheckFunctionArgs) {
5 | const { user, rule } = args;
6 | const { minRank } = rule.args as { minRank: number };
7 |
8 | const rank = await farRank({ fid: user.fid }).then((res) => (res === null ? Infinity : res));
9 |
10 | if (rank === Infinity) {
11 | console.error(`User's FarRank is not available: ${user.fid}`);
12 | return {
13 | result: false,
14 | message: "User's social FarRank is not available",
15 | };
16 | }
17 |
18 | return {
19 | result: rank <= minRank,
20 | message:
21 | rank <= minRank
22 | ? `User FarRank is #${rank.toLocaleString()}, higher than #${minRank.toLocaleString()}`
23 | : `User's FarRank is #${rank.toLocaleString()}, lower than #${minRank.toLocaleString()}`,
24 | };
25 | }
26 |
27 | type RuleName = "airstackSocialCapitalRank";
28 | export const airstackRulesFunction: Record = {
29 | airstackSocialCapitalRank,
30 | };
31 |
32 | export const airstackRulesDefinitions: Record = {
33 | airstackSocialCapitalRank: {
34 | name: "airstackSocialCapitalRank",
35 | author: "Airstack",
36 | authorUrl: "https://airstack.xyz",
37 | authorIcon: `/icons/airstack.png`,
38 | allowMultiple: false,
39 | hidden: false,
40 | category: "all",
41 | friendlyName: "FarRank by Airstack",
42 | checkType: "user",
43 | description: "Check if the user's Airstack FarRank is high enough.",
44 | invertable: false,
45 | args: {
46 | minRank: {
47 | type: "number",
48 | friendlyName: "Minimum Rank",
49 | required: true,
50 | placeholder: "e.g. 100",
51 | description: "Example: if you enter 100, the rule will check that the user's FarRank is 1 to 100.",
52 | },
53 | },
54 | },
55 | };
56 |
--------------------------------------------------------------------------------
/app/rules/bot-or-not.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
3 | type BotOrNotResponse = { fid: number; result: { bot?: boolean; status: "complete" | "analyzing" } };
4 |
5 | async function isHuman(args: CheckFunctionArgs) {
6 | const { user } = args;
7 | const rsp = await axios.get(
8 | `https://cast-action-bot-or-not.vercel.app/api/botornot/mod/v1?fid=${user.fid}&forceAnalyzeIfEmpty=true`,
9 | {
10 | timeout: 5_000,
11 | timeoutErrorMessage: "Bot or Not API timed out",
12 | }
13 | );
14 |
15 | const isBot = rsp.data.result.bot;
16 |
17 | if (isBot === undefined) {
18 | // retry later
19 | throw new Error(`Bot or not status for fid #${rsp.data.fid}: ${rsp.data.result.status}`);
20 | }
21 |
22 | return {
23 | result: !isBot,
24 | message: isBot ? "Bot detected by Bot Or Not" : "Human detected by Bot Or Not",
25 | };
26 | }
27 |
28 | type RuleName = "isHuman";
29 | export const botOrNotRulesFunction: Record = {
30 | isHuman: isHuman,
31 | };
32 |
33 | export const botOrNotRulesDefinitions: Record = {
34 | isHuman: {
35 | name: "isHuman",
36 | author: "botornot",
37 | authorUrl: "https://warpcast.com/botornot",
38 | authorIcon: `/icons/botornot.png`,
39 | allowMultiple: false,
40 | checkType: "user",
41 | category: "all",
42 | friendlyName: "Proof of Human, by Bot or Not",
43 | description: "Check if the user is a human using Bot Or Not",
44 | hidden: false,
45 | fidGated: [5179],
46 | invertable: false,
47 | args: {},
48 | },
49 | };
50 |
--------------------------------------------------------------------------------
/app/rules/fantoken.ts:
--------------------------------------------------------------------------------
1 | import { holdingFanTokenBalance } from "~/lib/airstack.server";
2 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
3 |
4 | async function holdsChannelFanToken(args: CheckFunctionArgs) {
5 | const { user, rule, channel } = args;
6 | const { contractAddress, minBalance, symbol } = rule.args;
7 | const balance = await holdingFanTokenBalance({ fid: user.fid, symbol });
8 | const hasEnough = balance >= parseFloat(minBalance);
9 |
10 | return {
11 | result: hasEnough,
12 | message: hasEnough
13 | ? `Holds /${channel.id} Fan Token`
14 | : `Needs to hold ${minBalance} /${channel.id} Channel Fan Token`,
15 | };
16 | }
17 |
18 | async function holdsFanToken(args: CheckFunctionArgs) {
19 | const { user, rule } = args;
20 | const {
21 | minBalance,
22 | fanToken: { value: contractAddress, label, symbol },
23 | } = rule.args;
24 |
25 | const balance = await holdingFanTokenBalance({ fid: user.fid, symbol });
26 | const hasEnough = balance >= parseFloat(minBalance);
27 | return {
28 | result: hasEnough,
29 | message: hasEnough ? `User holds @${label}'s Fan Token` : `Needs to hold ${minBalance} @${label}'s Fan Token`,
30 | };
31 | }
32 |
33 | type RuleName = "holdsChannelFanToken" | "holdsFanToken";
34 |
35 | export const fantokenRulesFunction: Record = {
36 | holdsChannelFanToken: holdsChannelFanToken,
37 | holdsFanToken: holdsFanToken,
38 | };
39 |
40 | export const fantokenRulesDefinitions: Record = {
41 | holdsFanToken: {
42 | name: "holdsFanToken",
43 | author: "Moxie",
44 | authorUrl: "https://moxie.xyz",
45 | authorIcon: `/icons/moxie.png`,
46 | allowMultiple: true,
47 | category: "inclusion",
48 | friendlyName: "Moxie Fan Token",
49 | checkType: "user",
50 | description: "Check if the user holds a Moxie fan token",
51 | hidden: false,
52 | invertable: false,
53 | args: {
54 | fanToken: {
55 | type: "moxieMemberFanTokenPicker",
56 | required: true,
57 | friendlyName: "Fan Token",
58 | placeholder: "Enter a username...",
59 | description: "If you don't see the token you're looking for, it may not be available yet. Check airstack.xyz",
60 | },
61 | minBalance: {
62 | type: "string",
63 | required: false,
64 | placeholder: "Any Amount",
65 | pattern: "^[0-9]+(\\.[0-9]+)?$",
66 | friendlyName: "Minimum Balance",
67 | description: "The minimum amount of fan tokens the user must hold.",
68 | },
69 | },
70 | },
71 |
72 | holdsChannelFanToken: {
73 | name: "holdsChannelFanToken",
74 | author: "Moxie",
75 | authorUrl: "https://moxie.xyz",
76 | authorIcon: `/icons/moxie.png`,
77 | allowMultiple: false,
78 | category: "inclusion",
79 | friendlyName: "Moxie Channel Fan Token",
80 | checkType: "cast",
81 | description: "Check if the user holds the fan token for your channel",
82 | hidden: false,
83 | invertable: false,
84 | args: {
85 | minBalance: {
86 | type: "string",
87 | required: false,
88 | placeholder: "Any Amount",
89 | friendlyName: "Minimum Balance",
90 | pattern: "^[0-9]+(\\.[0-9]+)?$",
91 | description: "The minimum amount of fan tokens the user must hold.",
92 | },
93 | },
94 | },
95 | };
96 |
--------------------------------------------------------------------------------
/app/rules/hypersub.ts:
--------------------------------------------------------------------------------
1 | import { getAddress, getContract } from "viem";
2 | import { polygon } from "viem/chains";
3 | import { hypersubAbi721 } from "~/lib/abis";
4 | import { formatHash } from "~/lib/utils.server";
5 | import { clientsByChainId } from "~/lib/viem.server";
6 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
7 |
8 | async function holdsActiveHypersub(args: CheckFunctionArgs) {
9 | const { user, rule } = args;
10 | const { chainId, contractAddress, name } = rule.args;
11 | const client = clientsByChainId[chainId];
12 |
13 | if (!client) {
14 | throw new Error(`No client found for chainId: ${chainId}`);
15 | }
16 |
17 | let isSubscribed = false;
18 | const contract = getContract({
19 | address: getAddress(contractAddress),
20 | abi: hypersubAbi721,
21 | client,
22 | });
23 |
24 | for (const address of [user.custody_address, ...user.verifications]) {
25 | const balance = await contract.read.balanceOf([getAddress(address)]);
26 | if (balance > 0) {
27 | isSubscribed = true;
28 | break;
29 | }
30 | }
31 |
32 | return {
33 | result: isSubscribed,
34 | message: isSubscribed
35 | ? `User holds an active hypersub (${name || formatHash(contractAddress)})`
36 | : `User does not hold an active hypersub (${name || formatHash(contractAddress)})`,
37 | };
38 | }
39 |
40 | type RuleName = "requireActiveHypersub";
41 |
42 | export const hypersubRulesDefinitions: Record = {
43 | requireActiveHypersub: {
44 | name: "requireActiveHypersub",
45 | author: "Hypersub",
46 | authorUrl: "https://hypersub.withfarbic.xyz",
47 | authorIcon: `/icons/fabric.svg`,
48 | allowMultiple: true,
49 | category: "all",
50 | friendlyName: "Subscribes on Hypersub",
51 | checkType: "user",
52 | description: "Check if the user has an active subscription to a hypersub.",
53 | hidden: false,
54 | invertable: true,
55 | args: {
56 | chainId: {
57 | type: "select",
58 | friendlyName: "Chain",
59 | description: "",
60 | required: true,
61 | options: [
62 | { value: "1", label: "Ethereum" },
63 | { value: "10", label: "Optimism" },
64 | { value: "8453", label: "Base" },
65 | { value: "7777777", label: "Zora" },
66 | { value: String(polygon.id), label: "Polygon" },
67 | ],
68 | },
69 | contractAddress: {
70 | type: "string",
71 | required: true,
72 | pattern: "0x[a-fA-F0-9]{40}",
73 | placeholder: "0xdead...",
74 | friendlyName: "Contract Address",
75 | description: "",
76 | },
77 | name: {
78 | type: "string",
79 | required: true,
80 | friendlyName: "Membership Name",
81 | placeholder: "e.g. Buoy Pro",
82 | description: "The name of the membership for display in the UI.",
83 | },
84 | },
85 | },
86 | };
87 |
88 | export const hypersubRulesFunction: Record = {
89 | requireActiveHypersub: holdsActiveHypersub,
90 | };
91 |
--------------------------------------------------------------------------------
/app/rules/membership-fee.ts:
--------------------------------------------------------------------------------
1 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
2 | import { db } from "~/lib/db.server";
3 |
4 | export async function membershipFeeRequired(args: CheckFunctionArgs) {
5 | const { user, rule, channel } = args;
6 | const { tokenName, feeAmount, receiveAddress } = rule.args;
7 |
8 | const channelOrder = await db.channelOrder.findFirst({
9 | where: {
10 | channelId: channel.id,
11 | fid: user.fid.toString(),
12 | address: receiveAddress,
13 | status: 1,
14 | },
15 | });
16 |
17 | const isPaid = !!channelOrder;
18 | return {
19 | result: isPaid,
20 | message: isPaid
21 | ? `User has paid the membership fee to join the channel`
22 | : `Membership fee required: ${feeAmount} ${tokenName}`,
23 | };
24 | }
25 |
26 | type RuleName = "membershipFeeRequired";
27 |
28 | export const membershipFeeRulesDefinitions: Record = {
29 | membershipFeeRequired: {
30 | name: "membershipFeeRequired",
31 | author: "modbot",
32 | authorUrl: "https://modbot.sh",
33 | authorIcon: `/icons/modbot.png`,
34 | allowMultiple: false,
35 | category: "all",
36 | friendlyName: " Membership Fee Required",
37 | checkType: "user",
38 | description: "Require the user pay a membership fee to join the channel",
39 | invertedDescription: "Check for users who *do not* pay a membership fee to join the channel",
40 | hidden: false,
41 | invertable: true,
42 | args: {
43 | tokenName: {
44 | type: "select",
45 | friendlyName: "Token Name",
46 | description: "",
47 | required: true,
48 | defaultValue: "ETH (Base Chain)",
49 | options: [{ value: "ETH (Base Chain)", label: "ETH (Base Chain)" }],
50 | },
51 | feeAmount: {
52 | type: "string",
53 | required: true,
54 | friendlyName: "Fee Amount",
55 | placeholder: "0.01",
56 | description: "",
57 | },
58 | receiveAddress: {
59 | type: "string",
60 | required: true,
61 | pattern: "0x[a-fA-F0-9]{40}",
62 | placeholder: "0xdead...",
63 | friendlyName: "Address to receive fees",
64 | description: "",
65 | },
66 | },
67 | },
68 | };
69 |
70 | export const membershipFeeRulesFunction: Record = {
71 | membershipFeeRequired: membershipFeeRequired,
72 | };
73 |
--------------------------------------------------------------------------------
/app/rules/membership.ts:
--------------------------------------------------------------------------------
1 | import { isChannelMember } from "~/lib/warpcast.server";
2 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
3 |
4 | async function userIsMemberOfChannel(args: CheckFunctionArgs) {
5 | const { user, rule } = args;
6 | const { channelSlug } = rule.args;
7 |
8 | const isMember = await isChannelMember({ channel: channelSlug, fid: user.fid });
9 | return {
10 | result: isMember,
11 | message: isMember ? `User is member of /${channelSlug}` : `User is not member of /${channelSlug}`,
12 | };
13 | }
14 |
15 | type RuleName = "userIsChannelMember";
16 | export const channelMemberRulesFunction: Record = {
17 | userIsChannelMember: userIsMemberOfChannel,
18 | };
19 |
20 | export const channelMemberRulesDefinitions: Record = {
21 | userIsChannelMember: {
22 | name: "userIsChannelMember",
23 | author: "modbot",
24 | authorUrl: "https://modbot.sh",
25 | authorIcon: `/icons/modbot.png`,
26 | allowMultiple: true,
27 | category: "all",
28 | friendlyName: "Channel Member",
29 | checkType: "user",
30 | hidden: false,
31 | invertable: false,
32 | description: "Check if the user is a member of a channel",
33 | args: {
34 | channelSlug: {
35 | type: "string",
36 | friendlyName: "Channel ID",
37 | placeholder: "replyguys",
38 | required: true,
39 | pattern: "/^[a-zA-Z0-9-]+$/",
40 | description: "The id of the channel to check",
41 | },
42 | },
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/app/rules/paragrah.ts:
--------------------------------------------------------------------------------
1 | import { checkSubscribesOnParagraph } from "~/lib/neynar.server";
2 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
3 |
4 | async function subscribesOnParagraph(args: CheckFunctionArgs) {
5 | const { user, rule } = args;
6 | const { farcasterUser } = rule.args as { farcasterUser: { value: number; label: string; icon: string } };
7 | const isSubbed = await checkSubscribesOnParagraph({ fid: user.fid, value: farcasterUser.value });
8 |
9 | return {
10 | result: isSubbed,
11 | message: isSubbed
12 | ? `User is subscribed to @${farcasterUser.label} on Paragraph `
13 | : `User is not subscribed to @${farcasterUser.label} on Paragraph`,
14 | };
15 | }
16 |
17 | type RuleName = "subscribesOnParagraph";
18 | export const paragraphRulesFunction: Record = {
19 | subscribesOnParagraph,
20 | };
21 |
22 | export const paragraphRulesDefinitions: Record = {
23 | subscribesOnParagraph: {
24 | name: "subscribesOnParagraph",
25 | author: "Paragraph",
26 | authorUrl: "https://paragraph.xyz",
27 | authorIcon: `/icons/paragraph2.png`,
28 | allowMultiple: true,
29 | category: "all",
30 | friendlyName: "Subscribes on Paragraph",
31 | checkType: "user",
32 | description: "Check if the user has an active subscription on paragraph.xyz",
33 | hidden: false,
34 | invertable: false,
35 | args: {
36 | farcasterUser: {
37 | type: "farcasterUserPicker",
38 | friendlyName: "Farcaster Username",
39 | required: true,
40 | description: "The farcaster user who owns the paragraph publication.",
41 | },
42 | },
43 | },
44 | };
45 |
--------------------------------------------------------------------------------
/app/rules/powerbadge.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { getSetCache } from "~/lib/utils.server";
3 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
4 |
5 | async function getPowerBadge() {
6 | const user = getSetCache({
7 | key: `powerbadge`,
8 | get: async () => {
9 | const response = await axios.get<{ result: { fids: number[] } }>(
10 | `https://api.neynar.com/v2/farcaster/user/power_lite`,
11 | {
12 | headers: {
13 | api_key: process.env.NEYNAR_API_KEY!,
14 | },
15 | }
16 | );
17 | return response.data.result.fids;
18 | },
19 | ttlSeconds: 60 * 60 * 4,
20 | });
21 |
22 | return user;
23 | }
24 |
25 | async function userHoldsPowerBadge(args: CheckFunctionArgs) {
26 | const { user } = args;
27 | const { fid } = user;
28 | const powerBadge = await getPowerBadge();
29 | const hasPowerBadge = powerBadge.includes(fid);
30 | return {
31 | result: hasPowerBadge,
32 | message: hasPowerBadge ? "User holds a power badge" : "User does not hold a power badge",
33 | };
34 | }
35 |
36 | type RuleName = "userDoesNotHoldPowerBadge";
37 | export const powerbadgeRulesFunction: Record = {
38 | userDoesNotHoldPowerBadge: userHoldsPowerBadge,
39 | };
40 |
41 | export const powerbadgeRulesDefinitions: Record = {
42 | userDoesNotHoldPowerBadge: {
43 | name: "userDoesNotHoldPowerBadge",
44 | author: "neynar",
45 | authorUrl: "https://neynar.com/",
46 | authorIcon: `/icons/neynar.png`,
47 | allowMultiple: false,
48 | category: "all",
49 | friendlyName: "Power Badge",
50 | checkType: "user",
51 | description: "Verify if the user has a power badge, issued to users likely not to be spammers",
52 | invertedDescription: "Check for users who *do* hold the power badge",
53 | hidden: false,
54 | invertable: true,
55 | args: {},
56 | },
57 | };
58 |
--------------------------------------------------------------------------------
/app/rules/regular.ts:
--------------------------------------------------------------------------------
1 | import { db } from "~/lib/db.server";
2 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
3 |
4 | async function manuallyApprove(args: CheckFunctionArgs) {
5 | // remove existing moderation logs
6 | const { channel, user } = args;
7 | await db.moderationLog.deleteMany({
8 | where: {
9 | channelId: channel.id,
10 | affectedUserFid: user.fid.toString(),
11 | },
12 | });
13 | return {
14 | result: false,
15 | message: "Need manual approval by channel moderators",
16 | };
17 | }
18 |
19 | function and(args: CheckFunctionArgs) {
20 | return { result: true, message: "And rule always passes" };
21 | }
22 |
23 | function or(args: CheckFunctionArgs) {
24 | return { result: true, message: "Or rule always passes" };
25 | }
26 |
27 | function alwaysInclude(args: CheckFunctionArgs) {
28 | return { result: true, message: "Everything included by default" };
29 | }
30 |
31 | type RuleName = "manuallyApprove" | "and" | "or" | "alwaysInclude";
32 | export const regularRulesFunction: Record = {
33 | manuallyApprove,
34 | and,
35 | or,
36 | alwaysInclude,
37 | };
38 |
39 | export const regularRulesDefinitions: Record = {
40 | and: {
41 | author: "modbot",
42 | authorUrl: "https://modbot.sh",
43 | authorIcon: `/icons/modbot.png`,
44 | allowMultiple: true,
45 | name: "and",
46 | category: "all",
47 | friendlyName: "And",
48 | checkType: "cast",
49 | description: "Combine multiple rules together",
50 | hidden: true,
51 | invertable: false,
52 | args: {},
53 | },
54 |
55 | or: {
56 | author: "modbot",
57 | authorUrl: "https://modbot.sh",
58 | authorIcon: `/icons/modbot.png`,
59 | allowMultiple: true,
60 | name: "or",
61 | category: "all",
62 | friendlyName: "Or",
63 | checkType: "cast",
64 | hidden: true,
65 | invertable: false,
66 | description: "Combine multiple rules together",
67 | args: {},
68 | },
69 |
70 | alwaysInclude: {
71 | name: "alwaysInclude",
72 | author: "modbot",
73 | authorUrl: "https://modbot.sh",
74 | authorIcon: `/icons/modbot.png`,
75 | allowMultiple: false,
76 | category: "inclusion",
77 | friendlyName: "Anyone Can Join",
78 | checkType: "cast",
79 | description: "Anyone can join your channel.",
80 | hidden: false,
81 | invertable: false,
82 | args: {},
83 | },
84 | manuallyApprove: {
85 | name: "manuallyApprove",
86 | author: "modbot",
87 | authorUrl: "https://modbot.sh",
88 | authorIcon: `/icons/modbot.png`,
89 | allowMultiple: false,
90 | category: "inclusion",
91 | friendlyName: " Manually Approve",
92 | checkType: "cast",
93 | description: "Manually approve member requests in Activity tab",
94 | hidden: false,
95 | invertable: false,
96 | args: {},
97 | },
98 | };
99 |
--------------------------------------------------------------------------------
/app/rules/rules.type.ts:
--------------------------------------------------------------------------------
1 | import { ModeratedChannel } from "@prisma/client";
2 | import { PlanType } from "~/lib/utils";
3 | import { z } from "zod";
4 | import { Cast } from "@neynar/nodejs-sdk/build/neynar-api/v2";
5 |
6 | export const ruleNames = [
7 | "and",
8 | "or",
9 | "isHuman",
10 | "alwaysInclude",
11 | "manuallyApprove",
12 | "airstackSocialCapitalRank",
13 | "openRankGlobalEngagement",
14 | "openRankChannel",
15 | "subscribesOnParagraph",
16 | "holdsFanToken",
17 | "holdsChannelFanToken",
18 | "userProfileContainsText",
19 | "userDisplayNameContainsText",
20 | "webhook",
21 | "userIsChannelMember",
22 | "userFollowerCount",
23 | "userDoesNotFollow",
24 | "userIsNotFollowedBy",
25 | "userDoesNotHoldPowerBadge",
26 | "userFidInList",
27 | "userFidInRange",
28 | "requireActiveHypersub",
29 | "requiresErc1155",
30 | "requiresErc721",
31 | "requiresErc20",
32 | "hasIcebreakerHuman",
33 | "hasIcebreakerBot",
34 | "hasIcebreakerQBuilder",
35 | "hasIcebreakerVerified",
36 | "hasIcebreakerCredential",
37 | "hasIcebreakerLinkedAccount",
38 | "hasPOAP",
39 | "hasGuildRole",
40 | "membershipFeeRequired",
41 | "containsText",
42 | "containsEmbeds",
43 | "textMatchesPattern",
44 | "textMatchesLanguage",
45 | "castLength",
46 | "containsTooManyMentions",
47 | "containsLinks",
48 | ] as const;
49 |
50 | export type RuleName = (typeof ruleNames)[number];
51 |
52 | export type RuleDefinition = {
53 | name: RuleName;
54 | author: string;
55 | authorUrl?: string;
56 | authorIcon?: string;
57 | minimumPlan?: PlanType;
58 | icon?: string;
59 | friendlyName: string;
60 |
61 | // Gate rule access to fids
62 | fidGated?: Array;
63 |
64 | // Gate rule access to channels
65 | channelGated?: Array;
66 | checkType: "user" | "cast";
67 | description: string;
68 |
69 | // Where this rule can be used
70 | category: "all" | "inclusion" | "exclusion" | "cast";
71 |
72 | // Whether this rule can be used multiple times in a rule set
73 | // example: containsText can be used many times, power badge can't
74 | allowMultiple: boolean;
75 | invertedDescription?: string;
76 | hidden: boolean | (() => boolean);
77 | invertable: boolean;
78 | args: Record<
79 | string,
80 | {
81 | type: string;
82 | defaultValue?: string | number | boolean;
83 | placeholder?: string;
84 | friendlyName: string;
85 | description: string;
86 | pattern?: string;
87 | tooltip?: string;
88 | required?: boolean;
89 | options?: Array<{ value: string; label: string; hint?: string }>;
90 | }
91 | >;
92 | };
93 |
94 | export type CheckFunctionResult = {
95 | result: boolean;
96 | message: string;
97 | };
98 | export type CheckFunction = (props: CheckFunctionArgs) => CheckFunctionResult | Promise;
99 |
100 | export const BaseRuleSchema = z.object({
101 | name: z.enum(ruleNames),
102 | type: z.union([z.literal("CONDITION"), z.literal("LOGICAL")]),
103 | args: z.record(z.any()),
104 | operation: z.union([z.literal("AND"), z.literal("OR")]).optional(),
105 | });
106 |
107 | export type Rule = z.infer & {
108 | conditions?: Rule[];
109 | };
110 | export type User = {
111 | fid: number;
112 | verifications: string[];
113 | custody_address: string;
114 | username: string;
115 | display_name?: string;
116 | follower_count: number;
117 | following_count: number;
118 | pfp_url?: string;
119 | profile: {
120 | bio: {
121 | text: string;
122 | };
123 | };
124 | };
125 | export type CheckFunctionArgs = {
126 | channel: ModeratedChannel;
127 | user: User;
128 | rule: Rule;
129 | cast?: Cast;
130 | };
131 |
132 | export type SelectOption = {
133 | label: string;
134 | value: number;
135 | icon?: string;
136 | };
137 |
--------------------------------------------------------------------------------
/app/rules/user-fid.ts:
--------------------------------------------------------------------------------
1 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
2 |
3 | async function userFidInList(args: CheckFunctionArgs) {
4 | const { user, rule } = args;
5 | const { fids } = rule.args as { fids: Array<{ value: number; icon: string; label: string }> };
6 |
7 | const result = fids.some((f) => f.value === user.fid);
8 |
9 | return {
10 | result,
11 | message: result ? `@${user.username} is in the list` : `@${user.username} is not in the list`,
12 | };
13 | }
14 | // Rule: user fid must be in range
15 | function userFidInRange(args: CheckFunctionArgs) {
16 | const { user, rule } = args;
17 | const { minFid, maxFid } = rule.args as { minFid?: number; maxFid?: number };
18 |
19 | if (minFid) {
20 | if (user.fid < minFid) {
21 | return {
22 | result: true,
23 | message: `FID #${user.fid} is less than ${minFid}`,
24 | };
25 | }
26 | }
27 |
28 | if (maxFid) {
29 | if (user.fid > maxFid) {
30 | return {
31 | result: true,
32 | message: `FID #${user.fid} is greater than ${maxFid}`,
33 | };
34 | }
35 | }
36 |
37 | let failureMessage = "";
38 | if (minFid && maxFid) {
39 | failureMessage = `FID #${user.fid} is not between ${minFid} and ${maxFid}`;
40 | } else if (minFid) {
41 | failureMessage = `FID #${user.fid} is greater than ${minFid}`;
42 | } else if (maxFid) {
43 | failureMessage = `FID #${user.fid} is less than ${maxFid}`;
44 | }
45 |
46 | return {
47 | result: false,
48 | message: failureMessage,
49 | };
50 | }
51 |
52 | type RuleName = "userFidInList" | "userFidInRange";
53 |
54 | export const userFidRulesDefinitions: Record = {
55 | userFidInList: {
56 | name: "userFidInList",
57 | allowMultiple: false,
58 | author: "modbot",
59 | authorUrl: "https://modbot.sh",
60 | authorIcon: `/icons/modbot.png`,
61 | category: "all",
62 | friendlyName: "User in List",
63 | checkType: "user",
64 | description: "Check if the user is on a list",
65 | hidden: false,
66 | invertable: true,
67 | args: {
68 | fids: {
69 | type: "farcasterUserPickerMulti",
70 | friendlyName: "Farcaster Usernames",
71 | required: true,
72 | placeholder: "Enter a username...",
73 | description: "",
74 | },
75 | },
76 | },
77 |
78 | userFidInRange: {
79 | name: "userFidInRange",
80 | allowMultiple: false,
81 | author: "modbot",
82 | authorUrl: "https://modbot.sh",
83 | authorIcon: `/icons/modbot.png`,
84 | category: "all",
85 | friendlyName: "User FID",
86 | checkType: "user",
87 | description: "Check if the user's FID is less than or greater than a certain value",
88 | hidden: false,
89 | invertable: false,
90 | args: {
91 | minFid: {
92 | type: "number",
93 | friendlyName: "Less than",
94 | placeholder: "No Minimum",
95 | description: "Setting a value of 5 would trigger this rule if the fid is 1 thru 4",
96 | },
97 | maxFid: {
98 | type: "number",
99 | friendlyName: "More than",
100 | description: "Setting a value of 10 would trigger this rule if the fid is 11 or above.",
101 | },
102 | },
103 | },
104 | };
105 |
106 | export const userFidRulesFunction: Record = {
107 | userFidInList,
108 | userFidInRange,
109 | };
110 |
--------------------------------------------------------------------------------
/app/rules/webhook.ts:
--------------------------------------------------------------------------------
1 | import axios, { AxiosError } from "axios";
2 |
3 | import { CheckFunction, CheckFunctionArgs, RuleDefinition } from "~/rules/rules.type";
4 |
5 | export async function webhook(args: CheckFunctionArgs) {
6 | const { user, rule, channel } = args;
7 | const { url, failureMode } = rule.args;
8 |
9 | const maxTimeout = 5_000;
10 |
11 | // dont throw on 400
12 | return axios
13 | .post(
14 | url,
15 | {
16 | user,
17 | channel: {
18 | id: channel.id,
19 | },
20 | },
21 | {
22 | headers: {
23 | "x-webhook-secret": process.env.THIRDPARTY_WEBHOOK_SECRET,
24 | },
25 | timeout: maxTimeout,
26 | validateStatus: (status) => (status >= 200 && status < 300) || status === 400,
27 | }
28 | )
29 | .then((response) => {
30 | let message = response.data.message?.substring(0, 75);
31 | if (!message) {
32 | message = response.status === 200 ? "Webhook rule triggered" : "Webhook rule did not trigger";
33 | }
34 |
35 | return {
36 | result: response.status === 200,
37 | message,
38 | };
39 | })
40 | .catch((err: AxiosError) => {
41 | console.error(
42 | `[${args.channel.id}] webhook to ${url} failed`,
43 | err.response?.status,
44 | err.response?.statusText,
45 | err.response?.data
46 | );
47 |
48 | if (err.code === "ECONNABORTED") {
49 | return {
50 | result: failureMode === "trigger" ? true : false,
51 | message:
52 | failureMode === "trigger"
53 | ? `Webhook didn't respond within ${maxTimeout / 1000}s, rule is set to trigger on failure`
54 | : `Webhook did not respond within ${maxTimeout / 1000}s, rule is set to not trigger on failure. `,
55 | };
56 | } else {
57 | return {
58 | result: failureMode === "trigger" ? true : false,
59 | message:
60 | failureMode === "trigger"
61 | ? "Webhook failed but rule is set to trigger on failure"
62 | : "Webhook failed and rule is set to not trigger on failure",
63 | };
64 | }
65 | });
66 | }
67 |
68 | type RuleName = "webhook";
69 | export const webhookRulesFunction: Record = {
70 | webhook: webhook,
71 | };
72 |
73 | export const webhookRulesDefinitions: Record = {
74 | webhook: {
75 | name: "webhook",
76 | author: "modbot",
77 | authorUrl: "https://modbot.sh",
78 | authorIcon: `/icons/modbot.png`,
79 | allowMultiple: false,
80 | category: "all",
81 | friendlyName: "Webhook",
82 | checkType: "user",
83 | description: "Use an external service to determine if the user should be invited into the channel.",
84 | hidden: false,
85 | invertable: false,
86 | args: {
87 | url: {
88 | type: "string",
89 | friendlyName: "URL",
90 | placeholder: "https://example.com/webhook",
91 | required: true,
92 | description:
93 | "A post request will be made with { user, channel } data. If the webhook returns a 200, the rule will be triggered, if it returns a 400, it will not. Return a json response in either case with a message to include a reason in the activity logs. Maximum of 75 characters. A response must return within 5 seconds. Example: HTTP POST example.com/webhook { user, channel } -> 200 {'message': 'User belongs to BAYC club'}",
94 | },
95 | failureMode: {
96 | type: "select",
97 | required: true,
98 | friendlyName: "If the webhook fails or times out...",
99 | description:
100 | "Example: Let's say you have only this rule in the section \"When any of the following rules are met, then invite the user to channel.\". If you choose 'Trigger this rule' and the webhook fails, the user will be invited into channel. If you choose 'Do not trigger this rule', the user will not be invited into channel.",
101 | defaultValue: "doNotTrigger",
102 | options: [
103 | { value: "trigger", label: "Trigger this rule" },
104 | { value: "doNotTrigger", label: "Do not trigger this rule" },
105 | ],
106 | },
107 | },
108 | },
109 | };
110 |
--------------------------------------------------------------------------------
/bullboard/index.ts:
--------------------------------------------------------------------------------
1 | import { createBullBoard } from "@bull-board/api";
2 | import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
3 | import { ExpressAdapter } from "@bull-board/express";
4 | import express from "express";
5 | import { sweepQueue, webhookQueue } from "~/lib/bullish.server";
6 |
7 | const serverAdapter = new ExpressAdapter();
8 | serverAdapter.setBasePath("/ui");
9 |
10 | createBullBoard({
11 | queues: [new BullMQAdapter(sweepQueue), new BullMQAdapter(webhookQueue)],
12 | serverAdapter: serverAdapter,
13 | });
14 |
15 | const app = express();
16 |
17 | app.use("/ui", serverAdapter.getRouter());
18 | app.listen(8888, () => {
19 | console.log("Bullboard started on port 8888");
20 | });
21 |
--------------------------------------------------------------------------------
/bullboard/removejob.ts:
--------------------------------------------------------------------------------
1 | import { sweepQueue, sweepWorker } from "~/lib/bullish.server";
2 |
3 | export async function killSweeps() {
4 | await sweepQueue.obliterate({ force: true });
5 | }
6 |
7 | export async function killJob(jobId: string) {
8 | const job = await sweepQueue.getJob(jobId);
9 | try {
10 | job?.moveToFailed(new Error("Killed by admin"), "gm");
11 | job?.remove();
12 | } catch (e) {
13 | console.error(e);
14 | }
15 | }
16 |
17 | // killJob("sweep:degen");
18 | killSweeps();
19 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/root.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "~/components",
15 | "utils": "~/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/prisma/migrations/20240708015729_add_feedtype/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `feedType` to the `ModeratedChannel` table without a default value. This is not possible if the table is not empty.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "ModeratedChannel" ADD COLUMN "feedType" TEXT NOT NULL DEFAULT 'custom';
9 |
--------------------------------------------------------------------------------
/prisma/migrations/20240725061409_add_index_and_api_key/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "PartnerApiKey" (
3 | "id" TEXT NOT NULL,
4 | "key" TEXT NOT NULL,
5 | "name" TEXT NOT NULL,
6 | "expiresAt" TIMESTAMP(3) NOT NULL,
7 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8 | "updatedAt" TIMESTAMP(3) NOT NULL,
9 |
10 | CONSTRAINT "PartnerApiKey_pkey" PRIMARY KEY ("id")
11 | );
12 |
13 | -- CreateIndex
14 | CREATE UNIQUE INDEX "PartnerApiKey_key_key" ON "PartnerApiKey"("key");
15 |
16 | -- CreateIndex
17 | CREATE INDEX "ModerationLog_action_idx" ON "ModerationLog"("action");
18 |
--------------------------------------------------------------------------------
/prisma/migrations/20240816131710_add_plan_wallet_address/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "User" ADD COLUMN "planWalletAddress" TEXT;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20240903055328_add_prop_delay_check/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "PropagationDelay" (
3 | "id" TEXT NOT NULL,
4 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
5 | "arrivedAt" TIMESTAMPTZ(6),
6 | "updatedAt" TIMESTAMP(3) NOT NULL,
7 | "hash" TEXT NOT NULL,
8 | "src" TEXT NOT NULL,
9 | "dst" TEXT NOT NULL,
10 |
11 | CONSTRAINT "PropagationDelay_pkey" PRIMARY KEY ("id")
12 | );
13 |
--------------------------------------------------------------------------------
/prisma/migrations/20240903063448_wat/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `PropagationDelay` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropTable
8 | DROP TABLE "PropagationDelay";
9 |
10 | -- CreateTable
11 | CREATE TABLE "PropagationDelayCheck" (
12 | "id" TEXT NOT NULL,
13 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14 | "arrivedAt" TIMESTAMPTZ(6),
15 | "updatedAt" TIMESTAMP(3) NOT NULL,
16 | "hash" TEXT NOT NULL,
17 | "src" TEXT NOT NULL,
18 | "dst" TEXT NOT NULL,
19 |
20 | CONSTRAINT "PropagationDelayCheck_pkey" PRIMARY KEY ("id")
21 | );
22 |
--------------------------------------------------------------------------------
/prisma/migrations/20240919024329_drop_rule_sets/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the `RuleSet` table. If the table is not empty, all the data it contains will be lost.
5 |
6 | */
7 | -- DropForeignKey
8 | ALTER TABLE "RuleSet" DROP CONSTRAINT "RuleSet_channelId_fkey";
9 |
10 | -- DropTable
11 | DROP TABLE "RuleSet";
12 |
--------------------------------------------------------------------------------
/prisma/migrations/20240925210434_add_rule_in_log/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "ModerationLog" ADD COLUMN "rule" TEXT NOT NULL DEFAULT '{}';
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20241007044912_update_cast_log/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `replyCount` on the `CastLog` table. All the data in the column will be lost.
5 | - You are about to drop the column `updatedAt` on the `CastLog` table. All the data in the column will be lost.
6 | - The `status` column on the `CastLog` table would be dropped and recreated. This will lead to data loss if there is data in the column.
7 | - Added the required column `authorFid` to the `CastLog` table without a default value. This is not possible if the table is not empty.
8 | - Changed the type of `createdAt` on the `CastLog` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required.
9 |
10 | */
11 | -- AlterTable
12 | ALTER TABLE "CastLog" DROP COLUMN "replyCount",
13 | DROP COLUMN "updatedAt",
14 | ADD COLUMN "authorFid" INTEGER NOT NULL,
15 | ADD COLUMN "data" TEXT NOT NULL DEFAULT '{}',
16 | DROP COLUMN "createdAt",
17 | ADD COLUMN "createdAt" INTEGER NOT NULL,
18 | DROP COLUMN "status",
19 | ADD COLUMN "status" INTEGER NOT NULL DEFAULT 0;
20 |
--------------------------------------------------------------------------------
/prisma/migrations/20241008055905_add_cast_rule_set_and_banlist/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "ModeratedChannel" ADD COLUMN "castRuleSet" TEXT NOT NULL DEFAULT '{}',
3 | ADD COLUMN "disableBannedList" INTEGER NOT NULL DEFAULT 0;
4 |
--------------------------------------------------------------------------------
/prisma/migrations/20241008060703_remove_columns/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `banThreshold` on the `ModeratedChannel` table. All the data in the column will be lost.
5 | - You are about to drop the column `slowModeHours` on the `ModeratedChannel` table. All the data in the column will be lost.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "ModeratedChannel" DROP COLUMN "banThreshold",
10 | DROP COLUMN "slowModeHours";
11 |
--------------------------------------------------------------------------------
/prisma/migrations/20241010043138_add_slow_mode_hours/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "ModeratedChannel" ADD COLUMN "slowModeHours" INTEGER NOT NULL DEFAULT 0;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20241011164744_add_frames_field/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "ModeratedChannel" ADD COLUMN "frames" TEXT NOT NULL DEFAULT '{}';
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20241012162145_add_channel_order/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "ChannelOrder" (
3 | "id" TEXT NOT NULL,
4 | "fid" TEXT NOT NULL,
5 | "channelId" TEXT NOT NULL,
6 | "address" TEXT NOT NULL,
7 | "txHash" TEXT,
8 | "status" INTEGER NOT NULL DEFAULT 0,
9 | "createdAt" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
10 | "updatedAt" TIMESTAMPTZ(6),
11 |
12 | CONSTRAINT "ChannelOrder_pkey" PRIMARY KEY ("id")
13 | );
14 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { db } from "~/lib/db.server";
2 |
3 | async function seed() {
4 | const haole = await db.user.upsert({
5 | where: {
6 | id: "3346",
7 | },
8 | create: {
9 | id: "3346",
10 | name: "haole",
11 | role: "superadmin",
12 | plan: "prime",
13 | avatarUrl: "https://i.imgur.com/hekRUeM.png",
14 | },
15 | update: {
16 | id: "3346",
17 | role: "superadmin",
18 | plan: "prime",
19 | },
20 | });
21 | }
22 |
23 | seed()
24 | .catch((err: unknown) => {
25 | console.error(err);
26 | process.exit(1);
27 | })
28 | .finally(async () => {
29 | await db.$disconnect();
30 | });
31 |
--------------------------------------------------------------------------------
/public/1up.wav:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/1up.wav
--------------------------------------------------------------------------------
/public/about.txt:
--------------------------------------------------------------------------------
1 | This favicon was generated using the following font:
2 |
3 | - Font Title: Kode Mono
4 | - Font Author: Copyright 2023 The Kode Mono Project Authors (https://github.com/isaozler/kode-mono)
5 | - Font Source: http://fonts.gstatic.com/s/kodemono/v1/A2BLn5pb0QgtVEPFnlYkkaoBgw4qv9odq5my9DqTaOW2A3k.ttf
6 | - Font License: SIL Open Font License, 1.1 (http://scripts.sil.org/OFL))
7 |
--------------------------------------------------------------------------------
/public/actions/ban.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/ban.png
--------------------------------------------------------------------------------
/public/actions/bypass.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/bypass.png
--------------------------------------------------------------------------------
/public/actions/cooldown24.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/cooldown24.png
--------------------------------------------------------------------------------
/public/actions/curate.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/curate.png
--------------------------------------------------------------------------------
/public/actions/downvote.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/downvote.png
--------------------------------------------------------------------------------
/public/actions/hideQuietly.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/hideQuietly.png
--------------------------------------------------------------------------------
/public/actions/install-scoped.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/install-scoped.png
--------------------------------------------------------------------------------
/public/actions/no-actions-available.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/actions/no-actions-available.png
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-512x512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/android-chrome-512x512.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-120x120-precomposed.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/apple-touch-icon-120x120-precomposed.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/public/apple-touch-icon-precompose.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/apple-touch-icon-precompose.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/automod-cast-action-bg.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/automod-cast-action-bg.png
--------------------------------------------------------------------------------
/public/drukwide.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/drukwide.ttf
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/favicon.ico
--------------------------------------------------------------------------------
/public/fonts/inter-medium.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/fonts/inter-medium.ttf
--------------------------------------------------------------------------------
/public/fonts/kode-mono-bold.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/fonts/kode-mono-bold.ttf
--------------------------------------------------------------------------------
/public/glass.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/glass.png
--------------------------------------------------------------------------------
/public/icons/airstack.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/airstack.png
--------------------------------------------------------------------------------
/public/icons/automod.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/automod.png
--------------------------------------------------------------------------------
/public/icons/botornot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/botornot.png
--------------------------------------------------------------------------------
/public/icons/fabric.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/icons/hypersub.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/hypersub.png
--------------------------------------------------------------------------------
/public/icons/icebreaker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/icebreaker.png
--------------------------------------------------------------------------------
/public/icons/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/logo.png
--------------------------------------------------------------------------------
/public/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/public/icons/modbot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/modbot.png
--------------------------------------------------------------------------------
/public/icons/moxie.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/moxie.png
--------------------------------------------------------------------------------
/public/icons/neynar.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/neynar.png
--------------------------------------------------------------------------------
/public/icons/openrank.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/openrank.png
--------------------------------------------------------------------------------
/public/icons/paragraph2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/icons/paragraph2.png
--------------------------------------------------------------------------------
/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/logo.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "theme_color": "#0e0e0d",
3 | "background_color": "#f2f2f2",
4 | "display": "standalone",
5 | "scope": "/",
6 | "start_url": "/",
7 | "name": "modbot",
8 | "short_name": "modbot",
9 | "description": "Enforce channel norms with bots",
10 | "icons": [
11 | {
12 | "src": "/android-chrome-192x192.png",
13 | "sizes": "192x192",
14 | "type": "image/png"
15 | },
16 | {
17 | "src": "/android-chrome-512x512.png",
18 | "sizes": "512x512",
19 | "type": "image/png"
20 | }
21 | ]
22 | }
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
--------------------------------------------------------------------------------
/public/screenshots/screen.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/screenshots/screen.png
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
--------------------------------------------------------------------------------
/public/videos/automod-demo-complete.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/public/videos/automod-demo-complete.mp4
--------------------------------------------------------------------------------
/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | export default {
3 | browserNodeBuiltinsPolyfill: {
4 | modules: {
5 | buffer: true,
6 | },
7 | },
8 | ignoredRouteFiles: ["**/.*"],
9 | };
10 |
--------------------------------------------------------------------------------
/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/scripts/dbReset.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | source .env
4 |
5 | if [[ $DATABASE_URL != *"localhost:5432"* ]]; then
6 | echo "Database URL is not localhost:5432"
7 | exit 1
8 | fi
9 |
10 | prisma db push --force-reset && prisma db seed
--------------------------------------------------------------------------------
/start.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh -ex
2 |
3 | pnpm exec prisma migrate dev --name added_job_title
4 | pnpm exec prisma generate
5 | pnpm exec prisma migrate deploy
6 | pnpm exec prisma db seed
7 | pnpm run start
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | prefix: "",
11 | theme: {
12 | container: {
13 | center: true,
14 | padding: "2rem",
15 | screens: {
16 | "2xl": "1400px",
17 | },
18 | },
19 | extend: {
20 | fontFamily: {
21 | display: "DrukWide",
22 | },
23 | colors: {
24 | border: "hsl(var(--border))",
25 | input: "hsl(var(--input))",
26 | ring: "hsl(var(--ring))",
27 | background: "hsl(var(--background))",
28 | foreground: "hsl(var(--foreground))",
29 | primary: {
30 | DEFAULT: "hsl(var(--primary))",
31 | foreground: "hsl(var(--primary-foreground))",
32 | },
33 | secondary: {
34 | DEFAULT: "hsl(var(--secondary))",
35 | foreground: "hsl(var(--secondary-foreground))",
36 | },
37 | destructive: {
38 | DEFAULT: "hsl(var(--destructive))",
39 | foreground: "hsl(var(--destructive-foreground))",
40 | },
41 | muted: {
42 | DEFAULT: "hsl(var(--muted))",
43 | foreground: "hsl(var(--muted-foreground))",
44 | },
45 | accent: {
46 | DEFAULT: "hsl(var(--accent))",
47 | foreground: "hsl(var(--accent-foreground))",
48 | },
49 | popover: {
50 | DEFAULT: "hsl(var(--popover))",
51 | foreground: "hsl(var(--popover-foreground))",
52 | },
53 | card: {
54 | DEFAULT: "hsl(var(--card))",
55 | foreground: "hsl(var(--card-foreground))",
56 | },
57 | },
58 | borderRadius: {
59 | lg: "var(--radius)",
60 | md: "calc(var(--radius) - 2px)",
61 | sm: "calc(var(--radius) - 4px)",
62 | },
63 | keyframes: {
64 | "accordion-down": {
65 | from: { height: "0" },
66 | to: { height: "var(--radix-accordion-content-height)" },
67 | },
68 | "accordion-up": {
69 | from: { height: "var(--radix-accordion-content-height)" },
70 | to: { height: "0" },
71 | },
72 | },
73 | animation: {
74 | "accordion-down": "accordion-down 0.2s ease-out",
75 | "accordion-up": "accordion-up 0.2s ease-out",
76 | },
77 | },
78 | },
79 | plugins: [require("tailwindcss-animate")],
80 | };
81 |
--------------------------------------------------------------------------------
/tests/fixtures.ts:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/kale5195/ModBot/6a461be6af02dd2c40a9f78c8beaf918edff719a/tests/fixtures.ts
--------------------------------------------------------------------------------
/tests/setup.ts:
--------------------------------------------------------------------------------
1 | import { execSync } from "child_process";
2 | import { PrismaClient } from "@prisma/client";
3 | import { beforeAll, afterAll } from "vitest";
4 |
5 | export const prisma = new PrismaClient();
6 |
7 | beforeAll(async () => {
8 | await prisma.$disconnect();
9 |
10 | execSync("npx prisma migrate deploy --preview-feature", {
11 | env: { ...process.env, DATABASE_URL: "file:./test.db" },
12 | });
13 |
14 | await prisma.$connect();
15 | await clearDatabase();
16 | });
17 |
18 | afterAll(async () => {
19 | await clearDatabase();
20 | await prisma.$disconnect();
21 | });
22 |
23 | async function clearDatabase() {
24 | await prisma.moderationLog.deleteMany();
25 | await prisma.moderatedChannel.deleteMany();
26 | await prisma.user.deleteMany();
27 | await prisma.inviteCode.deleteMany();
28 | }
29 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "exclude": ["node_modules", "build", "public"],
4 | "compilerOptions": {
5 | "skipLibCheck": true,
6 | "lib": ["DOM", "DOM.Iterable", "ES2022"],
7 | "isolatedModules": true,
8 | "esModuleInterop": true,
9 | "jsx": "react-jsx",
10 | "moduleResolution": "Bundler",
11 | "resolveJsonModule": true,
12 | "target": "ES2022",
13 | "strict": true,
14 | "allowJs": true,
15 | "forceConsistentCasingInFileNames": true,
16 | "baseUrl": ".",
17 | "paths": {
18 | "~/*": ["./app/*"]
19 | },
20 |
21 | // Remix takes care of building everything in `remix build`.
22 | "noEmit": true
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/tsconfig.seed.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["./prisma/seed.ts"],
3 | "compilerOptions": {
4 | "lib": ["ES2020"],
5 | "target": "ES2020",
6 | "moduleResolution": "node",
7 | "module": "CommonJS",
8 | "esModuleInterop": true,
9 | "resolveJsonModule": true,
10 | "skipLibCheck": true,
11 | "experimentalDecorators": true,
12 | "outDir": "./prisma/seed",
13 | "baseUrl": ".",
14 | "paths": {
15 | "~/*": ["./app/*"]
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/vitest.config.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
3 | import react from "@vitejs/plugin-react";
4 | import tsconfigPaths from "vite-tsconfig-paths";
5 | import { defineConfig } from "vitest/config";
6 |
7 | export default defineConfig({
8 | plugins: [react(), tsconfigPaths()],
9 | test: {
10 | globals: true,
11 | setupFiles: ["./tests/setup.ts"],
12 | environment: "happy-dom",
13 | include: ["tests/unit/**/*.test.ts*"],
14 | },
15 | });
16 |
--------------------------------------------------------------------------------
|