Skip to content

Commit

Permalink
feat: neynar middleware (#87)
Browse files Browse the repository at this point in the history
* tweaks: refactor for middleware

* tweaks

* feat: neynar middleware

* feat: export types

* docs: neynar

* chore: changeset
  • Loading branch information
jxom authored Mar 6, 2024
1 parent c377528 commit b7031ff
Show file tree
Hide file tree
Showing 12 changed files with 328 additions and 10 deletions.
5 changes: 5 additions & 0 deletions .changeset/green-carrots-hide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"frog": minor
---

Added built-in middleware for Neynar. [Read more.](https://frog.fm/concepts/middleware#neynar)
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>
),
})
})
58 changes: 58 additions & 0 deletions site/pages/concepts/middleware.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,64 @@ Since Frog is built on top of [Hono](https://hono.dev), Frog supports Hono's mid

Middleware works before and after the `.frame` [handler](/reference/frog-frame#handler) by allowing you to manipulate the request and response before and after dispatching respectively. We recommend checking out the Hono [documentation on Middleware](https://hono.dev/guides/middleware) for a more in-depth understanding.

## Built-in Middlewares

### Neynar

Frog comes with a built-in middleware for [Neynar](https://neynar.com) which allows you to easily integrate Neynar features (such as the interactor of your frame, and frame cast) into Frog context.

```tsx twoslash
/** @jsxImportSource frog/jsx */
// ---cut---
import { Button, Frog } from 'frog'
import { type NeynarVariables, neynar } from 'frog/middlewares'

export const app = new Frog<{
Variables: NeynarVariables
// @log: ↑ 1. Inject variables to get type inference on context (`c.var`).
}>()

// @log: ↓ 2. Inject `neynar` middleware onto the app.
app.use(
neynar({
apiKey: 'NEYNAR_FROG_FM',
features: ['interactor', 'cast'],
}),
)

app.frame('/', (c) => {
// @log: ↓ 3. Use `c.var` to access Neynar variables!
const { displayName, followerCount } = c.var.interactor || {}
console.log('cast: ', c.var.cast)
console.log('interactor: ', c.var.interactor)
// ^?
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>
),
})
})
```

:::warning
Feel free to use our Neynar API Key: `"NEYNAR_FROG_FM"`.

However, please note that this API Key is for development purposes only – it is prone to rate-limiting.
It is recommended to use your own API Key in production. [See more](https://neynar.com/#get-started).
:::

## Custom Middleware

You can write your own Frog middleware. This is great if you want to share common logic across or frames or if you are developing a SDK for Frog users to hook into their frames.
Expand Down
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
7 changes: 7 additions & 0 deletions src/middlewares/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export {
neynar,
type NeynarVariables,
type NeynarCast,
type NeynarMiddlewareParameters,
type NeynarUser,
} from './neynar.js'
186 changes: 186 additions & 0 deletions src/middlewares/neynar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import type { MiddlewareHandler } from 'hono'
import { hexToBytes } from 'viem'
import { Message } from '../protobufs/generated/message_pb.js'
import type { Pretty } from '../types/utils.js'
import { messageToFrameData } from '../utils/verifyFrame.js'

export type NeynarVariables = {
/**
* The cast of the frame.
*/
cast?: Pretty<NeynarCast> | undefined
/**
* The user who interacted with the frame.
*/
interactor?: Pretty<NeynarUser> | undefined
}

export type NeynarMiddlewareParameters = {
/**
* Neynar API Key.
*/
apiKey: string
/**
* Set of features to enable and inject into context.
*
* - `interactor`: Fetches the user who interacted with the frame.
* - `cast`: Fetches the cast of the frame.
*/
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: NeynarCast
}

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: NeynarUser[]
}

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 NeynarCast = {
author: NeynarUser
embeds: { url: string }[]
// TODO: populate with real type.
frames: unknown
hash: string
mentionedProfiles: NeynarUser[]
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 NeynarUser = {
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,
}
}
Loading

0 comments on commit b7031ff

Please sign in to comment.