Skip to content

Commit

Permalink
feat: neynar middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
jxom committed Mar 6, 2024
1 parent cb1e425 commit 566c663
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 10 deletions.
2 changes: 2 additions & 0 deletions playground/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Button, Frog, TextInput } from 'frog'
import * as hubs from 'frog/hubs'

import { app as middlewareApp } from './middleware.js'
import { app as neynarApp } from './neynar.js'
import { app as routingApp } from './routing.js'
import { app as todoApp } from './todos.js'
import { app as transactionApp } from './transaction.js'
Expand Down Expand Up @@ -228,6 +229,7 @@ app.frame('/redirect-buttons', (c) => {
})

app.route('/middleware', middlewareApp)
app.route('/neynar', neynarApp)
app.route('/routing', routingApp)
app.route('/transaction', transactionApp)
app.route('/todos', todoApp)
58 changes: 58 additions & 0 deletions playground/src/neynar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { Button, Frog } from 'frog'
import { type NeynarVariables, neynar } from 'frog/middlewares'

export const app = new Frog<{
Variables: NeynarVariables
}>()

app.use(
neynar({
apiKey: 'NEYNAR_FROG_FM',
features: ['interactor', 'cast'],
}),
)

app.frame('/', (c) => {
return c.res({
action: '/guess',
image: (
<div
style={{
alignItems: 'center',
color: 'white',
display: 'flex',
justifyContent: 'center',
fontSize: 48,
height: '100%',
width: '100%',
}}
>
I can guess your name and follower count.
</div>
),
intents: [<Button>Go on</Button>],
})
})

app.frame('/guess', (c) => {
const { displayName, followerCount } = c.var.interactor || {}
console.log('interactor: ', c.var.interactor)
console.log('cast: ', c.var.cast)
return c.res({
image: (
<div
style={{
alignItems: 'center',
color: 'white',
display: 'flex',
justifyContent: 'center',
fontSize: 48,
height: '100%',
width: '100%',
}}
>
Greetings {displayName}, you have {followerCount} followers.
</div>
),
})
})
2 changes: 0 additions & 2 deletions src/frog-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -334,8 +334,6 @@ export class FrogBase<
// `c.req` is not serializable.
req: undefined,
state: getState(),
// `c.var` is not serializable.
var: undefined,
}
const frameImageParams = toSearchParams(queryContext)

Expand Down
1 change: 1 addition & 0 deletions src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { neynar, type NeynarVariables } from './neynar.js'
170 changes: 170 additions & 0 deletions src/middlewares/neynar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { MiddlewareHandler } from 'hono'
import { hexToBytes } from 'viem'
import { Message } from '../protobufs/generated/message_pb.js'
import { messageToFrameData } from '../utils/verifyFrame.js'

export type NeynarVariables = {
cast?: Cast | undefined
interactor?: User | undefined
}

export type NeynarMiddlewareParameters = {
apiKey: string
features: ('interactor' | 'cast')[]
}

export function neynar(
parameters: NeynarMiddlewareParameters,
): MiddlewareHandler<{
Variables: NeynarVariables
}> {
const { apiKey, features } = parameters
return async (c, next) => {
const { trustedData } = (await c.req.json().catch(() => {})) || {}
if (!trustedData) return await next()

// Note: We are not verifying here as we verify downstream (internal Frog handler).
const body = hexToBytes(`0x${trustedData.messageBytes}`)
const message = Message.fromBinary(body)
const frameData = messageToFrameData(message)

const {
castId: { fid: castFid, hash },
fid,
} = frameData

const [castResponse, usersResponse] = await Promise.all([
features.includes('cast')
? getCast({
apiKey,
hash,
})
: Promise.resolve(undefined),
features.includes('interactor')
? getUsers({ apiKey, castFid, fids: [fid] })
: Promise.resolve(undefined),
])

if (castResponse) c.set('cast', castResponse.cast)
if (usersResponse) {
const [user] = usersResponse.users
if (user) c.set('interactor', user)
}

await next()
}
}

///////////////////////////////////////////////////////////////////////////
// Utilities

const neynarApiUrl = 'https://api.neynar.com'

type GetCastParameters = { apiKey: string; hash: string }
type GetCastReturnType = {
cast: Cast
}

async function getCast({
apiKey,
hash,
}: GetCastParameters): Promise<GetCastReturnType> {
const response = await fetch(
`${neynarApiUrl}/v2/farcaster/cast?type=hash&identifier=${hash}`,
{
headers: {
api_key: apiKey,
'Content-Type': 'application/json',
},
},
).then((res) => res.json())
return camelCaseKeys(response) as GetCastReturnType
}

type GetUsersParameters = { apiKey: string; castFid: number; fids: number[] }
type GetUsersReturnType = {
users: User[]
}

async function getUsers({
apiKey,
castFid,
fids,
}: GetUsersParameters): Promise<GetUsersReturnType> {
const response = await fetch(
`${neynarApiUrl}/v2/farcaster/user/bulk?fids=${fids.join(
',',
)}&viewer_fid=${castFid}`,
{
headers: {
api_key: apiKey,
'Content-Type': 'application/json',
},
},
).then((res) => res.json())
return camelCaseKeys(response) as GetUsersReturnType
}

function camelCaseKeys(response: object): object {
if (!response) return response
if (typeof response !== 'object') return response
if (Array.isArray(response)) return response.map(camelCaseKeys)
return Object.fromEntries(
Object.entries(response).map(([key, value]) => [
key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()),
camelCaseKeys(value),
]),
)
}

///////////////////////////////////////////////////////////////////////////
// Types

export type Cast = {
author: User
embeds: { url: string }[]
// TODO: populate with real type.
frames: unknown
hash: string
mentionedProfiles: User[]
object: 'cast'
parentAuthor: { fid: number | null }
parentHash: string | null
parentUrl: string
reactions: {
likes: { fid: number; fname: string }[]
recasts: { fid: number; fname: string }[]
}
replies: { count: number }
rootParentUrl: string
text: string
threadHash: string
timestamp: string
}

export type User = {
activeStatus: 'active' | 'inactive'
custodyAddress: string
displayName: string
fid: number
followerCount: number
followingCount: number
object: 'user'
pfpUrl: string
profile: {
bio: {
text: string
mentionedProfiles: string[]
}
}
username: string
verifications: string[]
verifiedAddresses: {
ethAddresses: string[]
solAddresses: string[]
}
viewerContext?: {
following: boolean
followedBy: boolean
}
}
5 changes: 5 additions & 0 deletions src/middlewares/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"type": "module",
"types": "../_lib/middlewares/index.d.ts",
"module": "../_lib/middlewares/index.js"
}
9 changes: 5 additions & 4 deletions src/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
"types": "./_lib/jsx/jsx-dev-runtime/index.d.ts",
"default": "./_lib/jsx/jsx-dev-runtime/index.js"
},
"./middlewares": {
"types": "./_lib/middlewares/index.d.ts",
"default": "./_lib/middlewares/index.js"
},
"./next": {
"types": "./_lib/next/index.d.ts",
"default": "./_lib/next/index.js"
Expand Down Expand Up @@ -80,10 +84,7 @@
"license": "MIT",
"homepage": "https://frog.fm",
"repository": "wevm/frog",
"authors": [
"awkweb.eth",
"jxom.eth"
],
"authors": ["awkweb.eth", "jxom.eth"],
"funding": [
{
"type": "github",
Expand Down
3 changes: 1 addition & 2 deletions src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,10 +105,9 @@ export type FrameQueryContext<
path extends string = string,
//
_state = env['State'],
> = Omit<FrameContext<env, path, _state>, 'req' | 'var'> & {
> = Omit<FrameContext<env, path, _state>, 'req'> & {
req: undefined
state: _state
var: undefined
}

export type TransactionContext<
Expand Down
1 change: 0 additions & 1 deletion src/utils/requestQueryToContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,5 @@ export function requestQueryToContext<
return {
...queryContext,
req: c.req,
var: c.var,
}
}
2 changes: 1 addition & 1 deletion src/utils/verifyFrame.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export async function verifyFrame({
////////////////////////////////////////////////////////////////////
// Utilties

function messageToFrameData(message: Message): FrameData {
export function messageToFrameData(message: Message): FrameData {
const frameActionBody = message.data?.body.value as FrameActionBody
const frameData: FrameData = {
castId: {
Expand Down

0 comments on commit 566c663

Please sign in to comment.