mirror of
https://github.com/neynarxyz/create-farcaster-mini-app.git
synced 2025-11-16 08:08:56 -05:00
Add notification savings + webhook validation
This commit is contained in:
parent
af451b12a1
commit
8acf07b03e
@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@farcaster/frame-sdk": "^0.0.14",
|
"@farcaster/frame-sdk": "^0.0.14",
|
||||||
"@tanstack/react-query": "^5.61.0",
|
"@tanstack/react-query": "^5.61.0",
|
||||||
|
"@upstash/redis": "^1.34.3",
|
||||||
"next": "15.0.3",
|
"next": "15.0.3",
|
||||||
"react": "19.0.0-rc-66855b96-20241106",
|
"react": "19.0.0-rc-66855b96-20241106",
|
||||||
"react-dom": "19.0.0-rc-66855b96-20241106",
|
"react-dom": "19.0.0-rc-66855b96-20241106",
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import {
|
import { notificationDetailsSchema } from "@farcaster/frame-sdk";
|
||||||
SendNotificationRequest,
|
|
||||||
sendNotificationResponseSchema,
|
|
||||||
} from "@farcaster/frame-sdk";
|
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { setUserNotificationDetails } from "~/lib/kv";
|
||||||
|
import { sendFrameNotification } from "~/lib/notifs";
|
||||||
|
|
||||||
const requestSchema = z.object({
|
const requestSchema = z.object({
|
||||||
token: z.string(),
|
fid: z.number(),
|
||||||
url: z.string(),
|
notificationDetails: notificationDetailsSchema,
|
||||||
targetUrl: z.string(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
@ -22,45 +20,28 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(requestBody.data.url, {
|
await setUserNotificationDetails(
|
||||||
method: "POST",
|
requestBody.data.fid,
|
||||||
headers: {
|
requestBody.data.notificationDetails
|
||||||
"Content-Type": "application/json",
|
);
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
const sendResult = await sendFrameNotification({
|
||||||
notificationId: crypto.randomUUID(),
|
fid: requestBody.data.fid,
|
||||||
title: "Hello from Frames v2!",
|
title: "Test notification",
|
||||||
body: "This is a test notification",
|
body: "Sent at " + new Date().toISOString(),
|
||||||
targetUrl: requestBody.data.targetUrl,
|
|
||||||
tokens: [requestBody.data.token],
|
|
||||||
} satisfies SendNotificationRequest),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const responseJson = await response.json();
|
if (sendResult.state === "error") {
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
// Ensure correct response
|
|
||||||
const responseBody = sendNotificationResponseSchema.safeParse(responseJson);
|
|
||||||
if (responseBody.success === false) {
|
|
||||||
return Response.json(
|
|
||||||
{ success: false, errors: responseBody.error.errors },
|
|
||||||
{ status: 500 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fail when rate limited
|
|
||||||
if (responseBody.data.result.rateLimitedTokens.length) {
|
|
||||||
return Response.json(
|
|
||||||
{ success: false, error: "Rate limited" },
|
|
||||||
{ status: 429 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Response.json({ success: true });
|
|
||||||
} else {
|
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ success: false, error: responseJson },
|
{ success: false, error: sendResult.error },
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
);
|
);
|
||||||
|
} else if (sendResult.state === "rate_limit") {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: "Rate limited" },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Response.json({ success: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,39 +1,35 @@
|
|||||||
import {
|
import { eventPayloadSchema } from "@farcaster/frame-sdk";
|
||||||
encodedJsonFarcasterSignatureSchema,
|
|
||||||
eventPayloadSchema,
|
|
||||||
jsonFarcasterSignatureHeaderSchema,
|
|
||||||
} from "@farcaster/frame-sdk";
|
|
||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
|
import { verifyJsonFarcasterSignature } from "~/lib/jfs";
|
||||||
|
import {
|
||||||
|
deleteUserNotificationDetails,
|
||||||
|
setUserNotificationDetails,
|
||||||
|
} from "~/lib/kv";
|
||||||
|
import { sendFrameNotification } from "~/lib/notifs";
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
const requestJson = await request.json();
|
const requestJson = await request.json();
|
||||||
|
|
||||||
const requestBody =
|
let data;
|
||||||
encodedJsonFarcasterSignatureSchema.safeParse(requestJson);
|
try {
|
||||||
|
const verifySignatureResult = await verifyJsonFarcasterSignature(
|
||||||
if (requestBody.success === false) {
|
requestJson
|
||||||
return Response.json(
|
|
||||||
{ success: false, errors: requestBody.error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
);
|
||||||
|
if (verifySignatureResult.success === false) {
|
||||||
|
return Response.json(
|
||||||
|
{ success: false, error: verifySignatureResult.error },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
data = verifySignatureResult;
|
||||||
|
} catch {
|
||||||
|
return Response.json({ success: false }, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: verify signature
|
const fid = data.fid;
|
||||||
|
|
||||||
const headerData = JSON.parse(
|
|
||||||
Buffer.from(requestBody.data.header, "base64url").toString("utf-8")
|
|
||||||
);
|
|
||||||
const header = jsonFarcasterSignatureHeaderSchema.safeParse(headerData);
|
|
||||||
if (header.success === false) {
|
|
||||||
return Response.json(
|
|
||||||
{ success: false, errors: header.error.errors },
|
|
||||||
{ status: 400 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const fid = header.data.fid;
|
|
||||||
|
|
||||||
const payloadData = JSON.parse(
|
const payloadData = JSON.parse(
|
||||||
Buffer.from(requestBody.data.payload, "base64url").toString("utf-8")
|
Buffer.from(data.payload, "base64url").toString("utf-8")
|
||||||
);
|
);
|
||||||
const payload = eventPayloadSchema.safeParse(payloadData);
|
const payload = eventPayloadSchema.safeParse(payloadData);
|
||||||
|
|
||||||
@ -46,26 +42,34 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
switch (payload.data.event) {
|
switch (payload.data.event) {
|
||||||
case "frame_added":
|
case "frame_added":
|
||||||
console.log(
|
if (payload.data.notificationDetails) {
|
||||||
payload.data.notificationDetails
|
await setUserNotificationDetails(fid, payload.data.notificationDetails);
|
||||||
? `Got frame-added event for fid ${fid} with notification token ${payload.data.notificationDetails.token} and url ${payload.data.notificationDetails.url}`
|
await sendFrameNotification({
|
||||||
: `Got frame-added event for fid ${fid} with no notification details`
|
fid,
|
||||||
);
|
title: "Welcome to Frames v2",
|
||||||
|
body: "Frame is now added to your client",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await deleteUserNotificationDetails(fid);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "frame_removed":
|
case "frame_removed":
|
||||||
console.log(`Got frame-removed event for fid ${fid}`);
|
await deleteUserNotificationDetails(fid);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
case "notifications_enabled":
|
case "notifications_enabled":
|
||||||
console.log(
|
await setUserNotificationDetails(fid, payload.data.notificationDetails);
|
||||||
`Got notifications-enabled event for fid ${fid} with token ${
|
await sendFrameNotification({
|
||||||
payload.data.notificationDetails.token
|
fid,
|
||||||
} and url ${payload.data.notificationDetails.url} ${JSON.stringify(
|
title: "Ding ding ding",
|
||||||
payload.data
|
body: "Notifications are now enabled",
|
||||||
)}`
|
});
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
case "notifications_disabled":
|
case "notifications_disabled":
|
||||||
console.log(`Got notifications-disabled event for fid ${fid}`);
|
await deleteUserNotificationDetails(fid);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,10 @@ export default function Demo(
|
|||||||
useState<FrameNotificationDetails | null>(null);
|
useState<FrameNotificationDetails | null>(null);
|
||||||
const [sendNotificationResult, setSendNotificationResult] = useState("");
|
const [sendNotificationResult, setSendNotificationResult] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setNotificationDetails(context?.client.notificationDetails ?? null);
|
||||||
|
}, [context]);
|
||||||
|
|
||||||
const { address, isConnected } = useAccount();
|
const { address, isConnected } = useAccount();
|
||||||
const chainId = useChainId();
|
const chainId = useChainId();
|
||||||
|
|
||||||
@ -74,7 +78,7 @@ export default function Demo(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setContext(await sdk.context);
|
setContext(await sdk.context);
|
||||||
sdk.actions.ready();
|
sdk.actions.ready({});
|
||||||
};
|
};
|
||||||
if (sdk && !isSDKLoaded) {
|
if (sdk && !isSDKLoaded) {
|
||||||
setIsSDKLoaded(true);
|
setIsSDKLoaded(true);
|
||||||
@ -96,7 +100,6 @@ export default function Demo(
|
|||||||
|
|
||||||
const addFrame = useCallback(async () => {
|
const addFrame = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
// setAddFrameResult("");
|
|
||||||
setNotificationDetails(null);
|
setNotificationDetails(null);
|
||||||
|
|
||||||
const result = await sdk.actions.addFrame();
|
const result = await sdk.actions.addFrame();
|
||||||
@ -120,7 +123,7 @@ export default function Demo(
|
|||||||
|
|
||||||
const sendNotification = useCallback(async () => {
|
const sendNotification = useCallback(async () => {
|
||||||
setSendNotificationResult("");
|
setSendNotificationResult("");
|
||||||
if (!notificationDetails) {
|
if (!notificationDetails || !context) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,15 +133,17 @@ export default function Demo(
|
|||||||
mode: "same-origin",
|
mode: "same-origin",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
token: notificationDetails.token,
|
fid: context.user.fid,
|
||||||
url: notificationDetails.url,
|
notificationDetails,
|
||||||
targetUrl: window.location.href,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setSendNotificationResult("Success");
|
setSendNotificationResult("Success");
|
||||||
return;
|
return;
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
setSendNotificationResult("Rate limited");
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.text();
|
const data = await response.text();
|
||||||
@ -146,7 +151,7 @@ export default function Demo(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
setSendNotificationResult(`Error: ${error}`);
|
setSendNotificationResult(`Error: ${error}`);
|
||||||
}
|
}
|
||||||
}, [notificationDetails]);
|
}, [context, notificationDetails]);
|
||||||
|
|
||||||
const sendTx = useCallback(() => {
|
const sendTx = useCallback(() => {
|
||||||
sendTransaction(
|
sendTransaction(
|
||||||
@ -246,6 +251,20 @@ export default function Demo(
|
|||||||
</div>
|
</div>
|
||||||
<Button onClick={close}>Close Frame</Button>
|
<Button onClick={close}>Close Frame</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="font-2xl font-bold">Add to client & notifications</h2>
|
||||||
|
|
||||||
|
<div className="mt-2 mb-4 text-sm">
|
||||||
|
Client fid {context?.client.clientFid},
|
||||||
|
{context?.client.added
|
||||||
|
? " frame added to client,"
|
||||||
|
: " frame not added to client,"}
|
||||||
|
{notificationDetails
|
||||||
|
? " notifications enabled"
|
||||||
|
: " notifications disabled"}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
|
<div className="p-2 bg-gray-100 dark:bg-gray-800 rounded-lg my-2">
|
||||||
@ -254,26 +273,26 @@ export default function Demo(
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
{addFrameResult && (
|
{addFrameResult && (
|
||||||
<div className="mb-2">Add frame result: {addFrameResult}</div>
|
<div className="mb-2 text-sm">
|
||||||
)}
|
Add frame result: {addFrameResult}
|
||||||
<Button onClick={addFrame}>Add frame to client</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{notificationDetails && (
|
|
||||||
<div>
|
|
||||||
<h2 className="font-2xl font-bold">Notify</h2>
|
|
||||||
|
|
||||||
{sendNotificationResult && (
|
|
||||||
<div className="mb-2">
|
|
||||||
Send notification result: {sendNotificationResult}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="mb-4">
|
<Button onClick={addFrame} disabled={context?.client.added}>
|
||||||
<Button onClick={sendNotification}>Send notification</Button>
|
Add frame to client
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{sendNotificationResult && (
|
||||||
|
<div className="mb-2 text-sm">
|
||||||
|
Send notification result: {sendNotificationResult}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button onClick={sendNotification} disabled={!notificationDetails}>
|
||||||
|
Send notification
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h2 className="font-2xl font-bold">Wallet</h2>
|
<h2 className="font-2xl font-bold">Wallet</h2>
|
||||||
@ -461,8 +480,10 @@ function SendEth() {
|
|||||||
const renderError = (error: Error | null) => {
|
const renderError = (error: Error | null) => {
|
||||||
if (!error) return null;
|
if (!error) return null;
|
||||||
if (error instanceof BaseError) {
|
if (error instanceof BaseError) {
|
||||||
const isUserRejection = error.walk((e) => e instanceof UserRejectedRequestError)
|
const isUserRejection = error.walk(
|
||||||
|
(e) => e instanceof UserRejectedRequestError
|
||||||
|
);
|
||||||
|
|
||||||
if (isUserRejection) {
|
if (isUserRejection) {
|
||||||
return <div className="text-red-500 text-xs mt-1">Rejected by user.</div>;
|
return <div className="text-red-500 text-xs mt-1">Rejected by user.</div>;
|
||||||
}
|
}
|
||||||
@ -470,4 +491,3 @@ const renderError = (error: Error | null) => {
|
|||||||
|
|
||||||
return <div className="text-red-500 text-xs mt-1">{error.message}</div>;
|
return <div className="text-red-500 text-xs mt-1">{error.message}</div>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
62
src/lib/jfs.ts
Normal file
62
src/lib/jfs.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { ed25519 } from "@noble/curves/ed25519";
|
||||||
|
import {
|
||||||
|
encodedJsonFarcasterSignatureSchema,
|
||||||
|
jsonFarcasterSignatureHeaderSchema,
|
||||||
|
} from "@farcaster/frame-sdk";
|
||||||
|
import { isSignerValid } from "~/lib/neynar";
|
||||||
|
|
||||||
|
type VerifyJsonFarcasterSignatureResult =
|
||||||
|
| { success: false; error: unknown }
|
||||||
|
| { success: true; fid: number; payload: string };
|
||||||
|
|
||||||
|
export async function verifyJsonFarcasterSignature(
|
||||||
|
data: unknown
|
||||||
|
): Promise<VerifyJsonFarcasterSignatureResult> {
|
||||||
|
// Parse & decode
|
||||||
|
const body = encodedJsonFarcasterSignatureSchema.safeParse(data);
|
||||||
|
if (body.success === false) {
|
||||||
|
return { success: false, error: body.error.errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const headerData = JSON.parse(
|
||||||
|
Buffer.from(body.data.header, "base64url").toString("utf-8")
|
||||||
|
);
|
||||||
|
const header = jsonFarcasterSignatureHeaderSchema.safeParse(headerData);
|
||||||
|
if (header.success === false) {
|
||||||
|
return { success: false, error: header.error.errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = Buffer.from(body.data.signature, "base64url");
|
||||||
|
if (signature.byteLength !== 64) {
|
||||||
|
return { success: false, error: "Invalid signature length" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fid = header.data.fid;
|
||||||
|
const key = header.data.key;
|
||||||
|
|
||||||
|
// Verify that the signer belongs to the FID
|
||||||
|
try {
|
||||||
|
const validSigner = await isSignerValid({
|
||||||
|
fid,
|
||||||
|
signerPublicKey: key,
|
||||||
|
});
|
||||||
|
if (!validSigner) {
|
||||||
|
return { success: false, error: "Invalid signer" };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Error verifying signer" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedInput = new Uint8Array(
|
||||||
|
Buffer.from(body.data.header + "." + body.data.payload)
|
||||||
|
);
|
||||||
|
|
||||||
|
const keyBytes = Uint8Array.from(Buffer.from(key.slice(2), "hex"));
|
||||||
|
|
||||||
|
const verifyResult = ed25519.verify(signature, signedInput, keyBytes);
|
||||||
|
if (!verifyResult) {
|
||||||
|
return { success: false, error: "Invalid signature" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, fid, payload: body.data.payload };
|
||||||
|
}
|
||||||
32
src/lib/kv.ts
Normal file
32
src/lib/kv.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { FrameNotificationDetails } from "@farcaster/frame-sdk";
|
||||||
|
import { Redis } from "@upstash/redis";
|
||||||
|
|
||||||
|
const redis = new Redis({
|
||||||
|
url: process.env.KV_REST_API_URL,
|
||||||
|
token: process.env.KV_REST_API_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
function getUserNotificationDetailsKey(fid: number): string {
|
||||||
|
return `frames-v2-demo:user:${fid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUserNotificationDetails(
|
||||||
|
fid: number
|
||||||
|
): Promise<FrameNotificationDetails | null> {
|
||||||
|
return await redis.get<FrameNotificationDetails>(
|
||||||
|
getUserNotificationDetailsKey(fid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setUserNotificationDetails(
|
||||||
|
fid: number,
|
||||||
|
notificationDetails: FrameNotificationDetails
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.set(getUserNotificationDetailsKey(fid), notificationDetails);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUserNotificationDetails(
|
||||||
|
fid: number
|
||||||
|
): Promise<void> {
|
||||||
|
await redis.del(getUserNotificationDetailsKey(fid));
|
||||||
|
}
|
||||||
34
src/lib/neynar.ts
Normal file
34
src/lib/neynar.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
const apiKey = process.env.NEYNAR_API_KEY || "";
|
||||||
|
|
||||||
|
export async function isSignerValid({
|
||||||
|
fid,
|
||||||
|
signerPublicKey,
|
||||||
|
}: {
|
||||||
|
fid: number;
|
||||||
|
signerPublicKey: string;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const url = new URL("https://hub-api.neynar.com/v1/onChainSignersByFid");
|
||||||
|
url.searchParams.append("fid", fid.toString());
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"x-api-key": apiKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseJson = await response.json();
|
||||||
|
const signerPublicKeyLC = signerPublicKey.toLowerCase();
|
||||||
|
|
||||||
|
const signerExists = responseJson.events.find(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(event: any) =>
|
||||||
|
event.signerEventBody.key.toLowerCase() === signerPublicKeyLC
|
||||||
|
);
|
||||||
|
|
||||||
|
return signerExists;
|
||||||
|
}
|
||||||
65
src/lib/notifs.ts
Normal file
65
src/lib/notifs.ts
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
SendNotificationRequest,
|
||||||
|
sendNotificationResponseSchema,
|
||||||
|
} from "@farcaster/frame-sdk";
|
||||||
|
import { getUserNotificationDetails } from "~/lib/kv";
|
||||||
|
|
||||||
|
const appUrl = process.env.NEXT_PUBLIC_URL || "";
|
||||||
|
|
||||||
|
type SendFrameNotificationResult =
|
||||||
|
| {
|
||||||
|
state: "error";
|
||||||
|
error: unknown;
|
||||||
|
}
|
||||||
|
| { state: "no_token" }
|
||||||
|
| { state: "rate_limit" }
|
||||||
|
| { state: "success" };
|
||||||
|
|
||||||
|
export async function sendFrameNotification({
|
||||||
|
fid,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
}: {
|
||||||
|
fid: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
}): Promise<SendFrameNotificationResult> {
|
||||||
|
const notificationDetails = await getUserNotificationDetails(fid);
|
||||||
|
if (!notificationDetails) {
|
||||||
|
return { state: "no_token" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(notificationDetails.url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
notificationId: crypto.randomUUID(),
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
targetUrl: appUrl,
|
||||||
|
tokens: [notificationDetails.token],
|
||||||
|
} satisfies SendNotificationRequest),
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseJson = await response.json();
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
const responseBody = sendNotificationResponseSchema.safeParse(responseJson);
|
||||||
|
if (responseBody.success === false) {
|
||||||
|
// Malformed response
|
||||||
|
return { state: "error", error: responseBody.error.errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseBody.data.result.rateLimitedTokens.length) {
|
||||||
|
// Rate limited
|
||||||
|
return { state: "rate_limit" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: "success" };
|
||||||
|
} else {
|
||||||
|
// Error response
|
||||||
|
return { state: "error", error: responseJson };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
yarn.lock
12
yarn.lock
@ -1175,6 +1175,13 @@
|
|||||||
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
|
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.2.0.tgz#756641adb587851b5ccb3e095daf27ae581c8406"
|
||||||
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
|
integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==
|
||||||
|
|
||||||
|
"@upstash/redis@^1.34.3":
|
||||||
|
version "1.34.3"
|
||||||
|
resolved "https://registry.yarnpkg.com/@upstash/redis/-/redis-1.34.3.tgz#df0338f4983bba5141878e851be4fced494b44a0"
|
||||||
|
integrity sha512-VT25TyODGy/8ljl7GADnJoMmtmJ1F8d84UXfGonRRF8fWYJz7+2J6GzW+a6ETGtk4OyuRTt7FRSvFG5GvrfSdQ==
|
||||||
|
dependencies:
|
||||||
|
crypto-js "^4.2.0"
|
||||||
|
|
||||||
"@wagmi/connectors@5.5.0":
|
"@wagmi/connectors@5.5.0":
|
||||||
version "5.5.0"
|
version "5.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/@wagmi/connectors/-/connectors-5.5.0.tgz#94bf6730dfea0032426230cd45b49183ccefb714"
|
resolved "https://registry.yarnpkg.com/@wagmi/connectors/-/connectors-5.5.0.tgz#94bf6730dfea0032426230cd45b49183ccefb714"
|
||||||
@ -1949,6 +1956,11 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
|
|||||||
dependencies:
|
dependencies:
|
||||||
uncrypto "^0.1.3"
|
uncrypto "^0.1.3"
|
||||||
|
|
||||||
|
crypto-js@^4.2.0:
|
||||||
|
version "4.2.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.2.0.tgz#4d931639ecdfd12ff80e8186dba6af2c2e856631"
|
||||||
|
integrity sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==
|
||||||
|
|
||||||
cssesc@^3.0.0:
|
cssesc@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user