Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add farcaster contest bot #10335

Merged
merged 6 commits into from
Dec 19, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions libs/model/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ const {
ALCHEMY_PUBLIC_APP_KEY,
MEMBERSHIP_REFRESH_BATCH_SIZE,
MEMBERSHIP_REFRESH_TTL_SECONDS,
NEYNAR_BOT_UUID,
NEYNAR_API_KEY,
NEYNAR_CAST_CREATED_WEBHOOK_SECRET,
NEYNAR_REPLY_WEBHOOK_URL,
Expand Down Expand Up @@ -91,6 +92,7 @@ export const config = configure(
: 5,
FLAG_FARCASTER_CONTEST: FLAG_FARCASTER_CONTEST === 'true',
NEYNAR_API_KEY: NEYNAR_API_KEY,
NEYNAR_BOT_UUID: NEYNAR_BOT_UUID,
NEYNAR_CAST_CREATED_WEBHOOK_SECRET: NEYNAR_CAST_CREATED_WEBHOOK_SECRET,
NEYNAR_REPLY_WEBHOOK_URL: NEYNAR_REPLY_WEBHOOK_URL,
FARCASTER_ACTION_URL: FARCASTER_ACTION_URL,
Expand Down Expand Up @@ -195,6 +197,7 @@ export const config = configure(
MIN_USER_ETH: z.number(),
MAX_USER_POSTS_PER_CONTEST: z.number().int(),
FLAG_FARCASTER_CONTEST: z.boolean().nullish(),
NEYNAR_BOT_UUID: z.string().nullish(),
rbennettcw marked this conversation as resolved.
Show resolved Hide resolved
NEYNAR_API_KEY: z.string().nullish(),
NEYNAR_CAST_CREATED_WEBHOOK_SECRET: z.string().nullish(),
NEYNAR_REPLY_WEBHOOK_URL: z.string().nullish(),
Expand Down
28 changes: 27 additions & 1 deletion libs/model/src/contest/Contests.projection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
EvmEventSignatures,
commonProtocol as cp,
} from '@hicommonwealth/evm-protocols';
import { config } from '@hicommonwealth/model';
import { ContestScore, events } from '@hicommonwealth/schemas';
import { buildContestLeaderboardUrl, getBaseUrl } from '@hicommonwealth/shared';
import { QueryTypes } from 'sequelize';
import { z } from 'zod';
import { models } from '../database';
Expand All @@ -17,6 +19,8 @@ import {
decodeThreadContentUrl,
getChainNodeUrl,
getDefaultContestImage,
parseFarcasterContentUrl,
publishCast,
} from '../utils';

const log = logger(import.meta);
Expand Down Expand Up @@ -295,7 +299,9 @@ export function Contests(): Projection<typeof inputs> {
},

ContestContentAdded: async ({ payload }) => {
const { threadId } = decodeThreadContentUrl(payload.content_url);
const { threadId, isFarcaster } = decodeThreadContentUrl(
payload.content_url,
);
await models.ContestAction.create({
...payload,
contest_id: payload.contest_id || 0,
Expand All @@ -306,6 +312,26 @@ export function Contests(): Projection<typeof inputs> {
voting_power: '0',
created_at: new Date(),
});

// post confirmation via FC bot
if (isFarcaster) {
const contestManager = await models.ContestManager.findByPk(
payload.contest_address,
);
const leaderboardUrl = buildContestLeaderboardUrl(
getBaseUrl(config.APP_ENV),
contestManager!.community_id,
contestManager!.contest_address,
);
const { replyCastHash } = parseFarcasterContentUrl(
payload.content_url,
);
await publishCast(
replyCastHash,
({ username }) =>
`Hey @${username}, your entry has been submitted to the contest: ${leaderboardUrl}`,
);
}
},

ContestContentUpvoted: async ({ payload }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { config } from '../config';
import { models } from '../database';
import { mustExist } from '../middleware/guards';
import { emitEvent } from '../utils';
import { emitEvent, publishCast } from '../utils';

const log = logger(import.meta);

Expand All @@ -21,13 +21,19 @@ export function FarcasterReplyCastCreatedWebhook(): Command<
mustExist('Farcaster Cast Author FID', payload.data.author?.fid);

const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!);

// get user verified address
const { users } = await client.fetchBulkUsers([payload.data.author.fid]);
const verified_address = users[0].verified_addresses.eth_addresses.at(0);
if (!verified_address) {
log.warn(
'Farcaster verified address not found for reply cast created event- content will be ignored.',
);
await publishCast(
payload.data.hash,
({ username }) =>
`Hey @${username}, you need a verified address to participate in the contest.`,
);
return;
}

Expand Down
6 changes: 0 additions & 6 deletions libs/model/src/utils/buildFarcasterContentUrl.ts

This file was deleted.

14 changes: 14 additions & 0 deletions libs/model/src/utils/farcasterUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function buildFarcasterContentUrl(
parentCastHash: string,
replyCashHash: string,
) {
return `/farcaster/${parentCastHash}/${replyCashHash}`;
}

export function parseFarcasterContentUrl(url: string) {
const [, , parentCastHash, replyCastHash] = url.split('/');
return {
parentCastHash,
replyCastHash,
};
}
2 changes: 1 addition & 1 deletion libs/model/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export * from './buildFarcasterContentUrl';
export * from './buildFarcasterWebhookName';
export * from './decodeContent';
export * from './defaultAvatar';
export * from './denormalizedCountUtils';
export * from './farcasterUtils';
export * from './getDefaultContestImage';
export * from './getDelta';
export * from './makeGetBalancesOptions';
Expand Down
27 changes: 27 additions & 0 deletions libs/model/src/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
safeTruncateBody,
type AbiType,
} from '@hicommonwealth/shared';
import { NeynarAPIClient } from '@neynar/nodejs-sdk';
import { createHash } from 'crypto';
import { hasher } from 'node-object-hash';
import {
Expand Down Expand Up @@ -79,11 +80,13 @@ export function buildThreadContentUrl(communityId: string, threadId: number) {
export function decodeThreadContentUrl(contentUrl: string): {
communityId: string | null;
threadId: number | null;
isFarcaster: boolean;
} {
if (contentUrl.startsWith('/farcaster/')) {
return {
communityId: null,
threadId: null,
isFarcaster: true,
};
}
if (!contentUrl.includes('/discussion/')) {
Expand All @@ -95,6 +98,7 @@ export function decodeThreadContentUrl(contentUrl: string): {
return {
communityId,
threadId: parseInt(threadId, 10),
isFarcaster: false,
};
}

Expand Down Expand Up @@ -256,3 +260,26 @@ export function getSaltedApiKeyHash(apiKey: string, salt: string): string {
export function buildApiKeySaltCacheKey(address: string) {
return `salt_${address.toLowerCase()}`;
}

export async function publishCast(
replyCastHash: string,
messageBuilder: ({ username }: { username: string }) => string,
) {
const client = new NeynarAPIClient(config.CONTESTS.NEYNAR_API_KEY!);
try {
const {
result: { casts },
} = await client.fetchBulkCasts([replyCastHash]);
const username = casts[0].author.username!;
await client.publishCast(
config.CONTESTS.NEYNAR_BOT_UUID!,
messageBuilder({ username }),
{
replyTo: replyCastHash,
},
);
log.info(`FC bot published reply to ${replyCastHash}`);
} catch (err) {
log.error(`Failed to post as FC bot`, err as Error);
}
}
21 changes: 21 additions & 0 deletions libs/shared/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -426,3 +426,24 @@ export async function alchemyGetTokenPrices({
cause: { status: res.status, statusText: res.statusText },
});
}

export const getBaseUrl = (env: string) => {
rbennettcw marked this conversation as resolved.
Show resolved Hide resolved
switch (env) {
case 'local':
return 'http://localhost:8080';
case 'beta':
return 'https://qa.commonwealth.im';
case 'demo':
return 'https://demo.commonwealth.im';
default:
return `https://${PRODUCTION_DOMAIN}`;
}
};

export const buildContestLeaderboardUrl = (
baseUrl: string,
communityId: string,
contestAddress: string,
) => {
return `${baseUrl}/${communityId}/contests/${contestAddress}`;
};
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { Contest, config as modelConfig } from '@hicommonwealth/model';
import { Button } from 'frames.js/express';
import React from 'react';

import { PRODUCTION_DOMAIN } from '@hicommonwealth/shared';
import { buildContestLeaderboardUrl, getBaseUrl } from '@hicommonwealth/shared';
import { frames } from '../../config';

export const contestCard = frames(async (ctx) => {
Expand Down Expand Up @@ -70,6 +70,12 @@ export const contestCard = frames(async (ctx) => {
};
}

const leaderboardUrl = buildContestLeaderboardUrl(
getBaseUrl(config.APP_ENV),
contestManager.community_id,
contestManager.contest_address,
);

return {
title: contestManager.name,
image: (
Expand Down Expand Up @@ -109,11 +115,7 @@ export const contestCard = frames(async (ctx) => {
</div>
),
buttons: [
<Button
key="leaderboard"
action="link"
target={`${getBaseUrl()}/${contestManager.community_id}/contests/${contestManager.contest_address}`}
>
<Button key="leaderboard" action="link" target={leaderboardUrl}>
Leaderboard
</Button>,
<Button
Expand All @@ -130,19 +132,6 @@ export const contestCard = frames(async (ctx) => {
};
});

const getBaseUrl = () => {
switch (config.APP_ENV) {
case 'local':
return 'http://localhost:8080';
case 'beta':
return 'https://qa.commonwealth.im';
case 'demo':
return 'https://demo.commonwealth.im';
default:
return `https://${PRODUCTION_DOMAIN}`;
}
};

export const getActionInstallUrl = () => {
// add environment to button label in non-prod environments
let buttonLabel = 'Upvote+Content';
Expand Down
Loading