Skip to content

Commit

Permalink
feat: composer actions (#396)
Browse files Browse the repository at this point in the history
* feat: composer actions

* chore: changesets

* refactor: drop `RouteOptions` for composer action, add state

* nit: add todo

* feat: add `postComposerActionMessage` helper

* nit: tested composer actions

* fix: types

* nit: update `ComposerActionMessage` with `channelKey`
  • Loading branch information
dalechyn authored Jul 21, 2024
1 parent ddb510f commit d763d1a
Show file tree
Hide file tree
Showing 12 changed files with 296 additions and 6 deletions.
5 changes: 5 additions & 0 deletions .changeset/neat-hairs-battle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"frog": patch
---

Added support for Composer Actions. [See More](https://warpcast.notion.site/Draft-Composer-Actions-7f2b8739ee8447cc8a6b518c234b1eeb).
19 changes: 19 additions & 0 deletions playground/src/composerAction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Frog } from 'frog'

import { vars } from './ui.js'

export const app = new Frog({
ui: { vars },
title: 'Composer Action',
}).composerAction('/', async (c) => {
console.log(
`Composer Action call ${JSON.stringify(c.actionData, null, 2)} from ${
c.actionData.fid
}`,
)
// if (Math.random() > 0.5) return c.error({ message: 'Action failed :(' })
return c.res({
title: 'Some Composer Action',
url: 'https://example.com',
})
})
2 changes: 2 additions & 0 deletions playground/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { neynar } from 'frog/hubs'
import { Box, Heading, vars } from './ui.js'

import { app as castActionApp } from './castAction.js'
import { app as composerActionApp } from './composerAction.js'
import { app as fontsApp } from './fonts.js'
import { app as initial } from './initial.js'
import { app as middlewareApp } from './middleware.js'
Expand Down Expand Up @@ -193,6 +194,7 @@ export const app = new Frog({
return c.error({ message: 'Bad inputs!' })
})
.route('/castAction', castActionApp)
.route('/composerAction', composerActionApp)
.route('/initial', initial)
.route('/ui', uiSystemApp)
.route('/fonts', fontsApp)
Expand Down
1 change: 1 addition & 0 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { HtmlEscapedString } from 'hono/utils/html'

export const buttonPrefix = {
addCastAction: '_a',
addComposerAction: '_b',
link: '_l',
mint: '_m',
redirect: '_r',
Expand Down
39 changes: 39 additions & 0 deletions src/frog-base.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { Hub } from './types/hub.js'
import type {
BlankInput,
CastActionHandler,
ComposerActionHandler,
FrameHandler,
H,
HandlerInterface,
Expand All @@ -35,6 +36,7 @@ import type { Vars } from './ui/vars.js'
import { fromQuery } from './utils/fromQuery.js'
import { getButtonValues } from './utils/getButtonValues.js'
import { getCastActionContext } from './utils/getCastActionContext.js'
import { getComposerActionContext } from './utils/getComposerActionContext.js'
import { getFrameContext } from './utils/getFrameContext.js'
import { getImageContext } from './utils/getImageContext.js'
import { getImagePaths } from './utils/getImagePaths.js'
Expand Down Expand Up @@ -429,6 +431,43 @@ export class FrogBase<
return this
}

composerAction: HandlerInterface<env, 'composerAction', schema, basePath> = (
...parameters: any[]
) => {
const [path, middlewares, handler, options = {}] = getRouteParameters<
env,
ComposerActionHandler<env>,
'composerAction'
>(...parameters)

const { verify = this.verify } = options

// Composer Action Route (implements POST).
this.hono.post(parseHonoPath(path), ...middlewares, async (c) => {
const { context } = getComposerActionContext<env, string>({
context: await requestBodyToContext(c, {
hub:
this.hub ||
(this.hubApiUrl ? { apiUrl: this.hubApiUrl } : undefined),
secret: this.secret,
verify,
}),
})

const response = await handler(context)
if (response instanceof Response) return response
if (response.status === 'error') {
c.status(response.error.statusCode ?? 400)
return c.json({ message: response.error.message })
}

const { url: formUrl, title } = response.data
return c.json({ url: formUrl, title, type: 'form' })
})

return this
}

frame: HandlerInterface<env, 'frame', schema, basePath> = (
...parameters: any[]
) => {
Expand Down
7 changes: 7 additions & 0 deletions src/next/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
// TODO: Rename this package to `js` as most of it doesn't strictly depend
// on Next.JS specific features. Only `handle` does.

export { getFrameMetadata } from './getFrameMetadata.js'
export { handle } from '../vercel/index.js'
export { isFrameRequest } from './isFrameRequest.js'
export {
postComposerActionMessage,
postComposerCreateCastActionMessage,
} from './postComposerActionMessage.js'
39 changes: 39 additions & 0 deletions src/next/postComposerActionMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
export type ComposerActionMessage = {
type: 'createCast'
data: {
cast: {
channelKey?: string | undefined
embeds: string[]
parent?: string | undefined
text: string
}
}
}

/**
* Posts Composer Action Message to `window.parent`.
*/
export function postComposerActionMessage(message: ComposerActionMessage) {
if (typeof window === 'undefined')
throw new Error(
'`postComposerActionMessage` must be called in the Client Component.',
)

window.parent.postMessage(message, '*')
}

/**
* Posts Composer Create Cast Action Message to `window.parent`.
*
* This is a convinience method and it calls `postComposerActionMessage` under the hood.
*/
export function postComposerCreateCastActionMessage(
message: ComposerActionMessage['data']['cast'],
) {
return postComposerActionMessage({
type: 'createCast',
data: {
cast: message,
},
})
}
43 changes: 43 additions & 0 deletions src/types/composerAction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { TypedResponse } from './response.js'

export type ComposerActionResponse = {
/**
* Title of the action.
*/
title: string
/**
* URL of the form.
*
* @example https://example.com/form
*/
url: string
}

export type ComposerActionResponseFn = (
response: ComposerActionResponse,
) => TypedResponse<ComposerActionResponse>

export type ComposerActionData = {
buttonIndex: 1
castId: { fid: number; hash: string }
fid: number
messageHash: string
network: number
timestamp: number
url: string
state: {
requestId: string
cast: {
parent?: string | undefined
text: string
embeds: string[]
castDistribution: string
}
}
}

export type TrustedData = {
messageBytes: string
}

export type UntrustedData = ComposerActionData
53 changes: 53 additions & 0 deletions src/types/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import type {
CastActionMessageResponseFn,
CastActionResponseFn,
} from './castAction.js'
import type {
ComposerActionData,
ComposerActionResponseFn,
} from './composerAction.js'
import type { Env } from './env.js'
import type { FrameButtonValue, FrameData, FrameResponseFn } from './frame.js'
import type { ImageResponseFn } from './image.js'
Expand Down Expand Up @@ -83,6 +87,55 @@ export type CastActionContext<
verified: boolean
}

export type ComposerActionContext<
env extends Env = Env,
path extends string = string,
input extends Input = {},
> = {
/**
* Data from the action that was passed via the POST body.
* The {@link Context`verified`} flag indicates whether the data is trusted or not.
*/
actionData: Pretty<ComposerActionData>
/**
* `.env` can get bindings (environment variables, secrets, KV namespaces, D1 database, R2 bucket etc.) in Cloudflare Workers.
*
* @example
* ```ts
* // Environment object for Cloudflare Workers
* app.castAction('/', async c => {
* const counter = c.env.COUNTER
* })
* ```
* @see https://hono.dev/api/context#env
*/
env: Context_hono<env, path>['env']
/** Error response that includes message and statusCode. */
error: BaseErrorResponseFn
/**
* Hono request object.
*
* @see https://hono.dev/api/context#req
*/
req: Context_hono<env, path, input>['req']
/**
* Raw action response that includes action properties such as: message, statusCode.
*
* @see https://warpcast.notion.site/Spec-Farcaster-Actions-84d5a85d479a43139ea883f6823d8caa
* */
res: ComposerActionResponseFn
/**
* Extract a context value that was previously set via `set` in [Middleware](/concepts/middleware).
*
* @see https://hono.dev/api/context#var
*/
var: Context_hono<env, path, input>['var']
/**
* Whether or not the {@link Context`actionData`} was verified by the Farcaster Hub API.
*/
verified: boolean
}

export type Context<
env extends Env = Env,
path extends string = string,
Expand Down
8 changes: 7 additions & 1 deletion src/types/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ export type BaseError = { message: string; statusCode?: ClientErrorStatusCode }
export type BaseErrorResponseFn = (response: BaseError) => TypedResponse<never>

export type TypedResponse<data> = {
format: 'castAction' | 'frame' | 'transaction' | 'image' | 'signature'
format:
| 'castAction'
| 'composerAction'
| 'frame'
| 'transaction'
| 'image'
| 'signature'
} & OneOf<
{ data: data; status: 'success' } | { error: BaseError; status: 'error' }
>
Expand Down
22 changes: 17 additions & 5 deletions src/types/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import type {
} from 'hono/utils/types'
import type { FrogBase, RouteOptions } from '../frog-base.js'
import type { CastActionResponse } from './castAction.js'
import type { ComposerActionResponse } from './composerAction.js'
import type {
CastActionContext,
ComposerActionContext,
Context,
FrameContext,
ImageContext,
Expand Down Expand Up @@ -52,6 +54,14 @@ export type CastActionHandler<
I extends Input = BlankInput,
> = (c: CastActionContext<E, P, I>) => HandlerResponse<CastActionResponse>

export type ComposerActionHandler<
E extends Env = any,
P extends string = any,
I extends Input = BlankInput,
> = (
c: ComposerActionContext<E, P, I>,
) => HandlerResponse<ComposerActionResponse>

export type FrameHandler<
E extends Env = any,
P extends string = any,
Expand Down Expand Up @@ -102,11 +112,13 @@ export type H<
? TransactionHandler<E, P, I>
: M extends 'castAction'
? CastActionHandler<E, P, I>
: M extends 'image'
? ImageHandler<E, P, I>
: M extends 'signature'
? SignatureHandler<E, P, I>
: Handler<E, P, I, R>
: M extends 'composerAction'
? ComposerActionHandler<E, P, I>
: M extends 'image'
? ImageHandler<E, P, I>
: M extends 'signature'
? SignatureHandler<E, P, I>
: Handler<E, P, I, R>

////////////////////////////////////////
////// //////
Expand Down
64 changes: 64 additions & 0 deletions src/utils/getComposerActionContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { Input } from 'hono'
import type { ComposerActionContext, Context } from '../types/context.js'
import type { Env } from '../types/env.js'

type GetComposerActionContextParameters<
env extends Env = Env,
path extends string = string,
input extends Input = {},
> = {
context: Context<env, path, input>
}

type GetComposerActionContextReturnType<
env extends Env = Env,
path extends string = string,
input extends Input = {},
> = {
context: ComposerActionContext<env, path, input>
}

export function getComposerActionContext<
env extends Env,
path extends string,
input extends Input = {},
>(
parameters: GetComposerActionContextParameters<env, path, input>,
): GetComposerActionContextReturnType<env, path, input> {
const { context } = parameters
const { env, frameData, req, verified } = context || {}

if (!frameData)
throw new Error('Frame data must be present for action handlers.')
if (!frameData.state)
throw new Error('State must be present for composer action handler.')

return {
context: {
actionData: {
buttonIndex: 1,
castId: frameData.castId,
fid: frameData.fid,
network: frameData.network,
messageHash: frameData.messageHash,
timestamp: frameData.timestamp,
state: JSON.parse(decodeURIComponent(frameData.state)),
url: frameData.url,
},
env,
error: (data) => ({
error: data,
format: 'composerAction',
status: 'error',
}),
req,
res: (data) => ({
data,
format: 'composerAction',
status: 'success',
}),
var: context.var,
verified,
},
}
}

0 comments on commit d763d1a

Please sign in to comment.