Cover photo

How To Build A Farcaster Raycast Extension With Pinata

If you’ve followed our blog for the last few months, then you’ve probably picked up that we love building Farcaster clients (or at least I do). There’s something exciting about an open social graph that can be interfaced from so many different places. Additionally, the combination of IPFS gives the opportunity for sharing and consuming any kind of content, including, but not limited to, images and videos. Combine that with Raycast, a killer Mac OS productivity app? You get the best speed casting experience!

In this tutorial, we’ll show you how we built Raycaster, a Raycast extension used for sending casts to Farcaster. It will let you send text, links, and even images to your feed or to a channel of your choice! Not only that, but the principles you’ll learn here can be taken deeper into the Farcaster or Raycast ecosystem to build even more tools.

Setup

Before you get started on this fun extension, you’re going to need a few things.

Pinata Account

The first thing you’ll need is a Pinata account, which you can start for free by signing up here. For this tutorial you can use the default free account until you’ve uploaded too much and need to migrate. Once you create your account, all you need to do is create an API key with these instructions. That’s it!

Developer Environment

The great thing about building Raycast extensions is that it’s primarily React code that is compiled to native Swift for Mac OS, which results in a fantastic developer experience. With that said, you will probably want some experience with React, as well as Node.js, installed on your machine along with a trusty text editor.

Raycast

If you haven’t already, you will want to install Raycast by visiting the link here. Once you install it, you should be able to run Create Extension which will bring up a form to fill out. For the template I would recommend the Form as the starting point, and for the command just start with Send Cast as the name with a description. Once you fill it out and submit the form, it will generate the code base for you! Open up the terminal and cd into the folder, then run the following command to install some dependencies.

npm install @farcaster/core node-fetch @noble/hashes form-data

Now, go ahead and open the repo with your code editor of choice, then in the terminal, run npm run dev to have Raycast bring up your new extension!

Authorize

The first, and arguably the most, challenging part of sending a cast to Farcaster is the authorization, which we’ve talked about in depth before. We’ve even built our own Farcaster Auth to help developers scale posting to Farcaster with many users. However, for this particular extension, it gets a little tricky due to the restrictions of the UI and compatible libraries. Because of that, we’ll be taking a bit of an old fashioned approach to authorization. First, we’ve spun up an auth server with this repo which we’re using as a service to go along with the extension. If you wanted to do something similar you are welcome to fork the repo and deploy it yourself! With it, we can create and sign keys with our app FID and mnemonic securely, without asking users to paste in their own mnemonic to the extension. Now, back in our repo, you will want to make a new file under src called authorize.tsx. Then, inside the package.json file, we will want to add this block of code.

{
  "commands": [
    {
      "name": "send-cast", // Existing command
      "title": "Send Cast",
      "description": "Send a Cast to the Farcaster network. Requires a Signer Private Key and your FID",
      "mode": "view"
    },
    {
      "name": "authorize", // New Command
      "title": "Authorize Raycaster",
      "description": "In order to send a cast with Raycaster you first need to authorize it with this command",
      "mode": "view"
    }
  ]
}

This will make sure that our command is registered and accessible inside of Raycast.

Back in our authorize.tsx, let’s put in the following code:

import { useEffect, useState } from "react";
import { List, showToast, Toast, Icon } from "@raycast/api";
import { KeyRequestData, PollRequestData } from "./types";
import fetch from "node-fetch";
import { LocalStorage } from "@raycast/api";

export default function Command() {
  const [token, setToken] = useState<string | undefined>();
  const [complete, setComplete] = useState<boolean>(false);
  const [signInError, setSignInError] = useState<boolean>(false);

  async function createSigner() {
    try {
      const localKey = await LocalStorage.getItem("signerKey");
      if (localKey) {
        setComplete(true);
        return;
      }
      const keyReq = await fetch("https://api.farcasterkeys.com/sign-in", {
        method: "POST",
      });
      const keyRes = (await keyReq.json()) as KeyRequestData;
      if (!keyReq) {
        throw Error("Problem generating key");
      }
      console.log(keyRes);
      setToken(keyRes.pollingToken);
      const pollReq = await fetch(`https://api.farcasterkeys.com/sign-in/poll?token=${keyRes.pollingToken}`);
      const pollRes = (await pollReq.json()) as PollRequestData;
      console.log(pollRes);
      const pollStartTime = Date.now();
      while (pollRes.state != "completed") {
        if (Date.now() - pollStartTime > 120000) {
          await showToast({
            title: "Sign in Timed out",
            style: Toast.Style.Failure,
          });
          setSignInError(true);
          break;
        }
        const pollReq = await fetch(`https://api.farcasterkeys.com/sign-in/poll?token=${keyRes.pollingToken}`);
        const pollRes = (await pollReq.json()) as PollRequestData;
        console.log(pollRes);
        if (pollRes.state === "approved") {
          await LocalStorage.setItem("signerKey", keyRes.privateKey);
          await LocalStorage.setItem("fid", pollRes.userFid!);
          await showToast({
            title: "Sign In Complete!",
          });
          setComplete(true);
          return pollRes;
        }
        await new Promise((resolve) => setTimeout(resolve, 2000));
      }
    } catch (error) {
      console.log(error);
      await showToast({
        title: "Problem Authorizing",
        message: error as string,
        style: Toast.Style.Failure,
      });
    }
  }

  useEffect(() => {
    createSigner();
  }, []);

  return (
    <List>
      {complete && (
        <List.EmptyView icon={Icon.CheckCircle} title="Raycaster is authorized! Now you can use Send Cast" />
      )}
      {!complete && !signInError && (
        <List.EmptyView
          icon={{ source: `https://api.farcasterkeys.com/qr/${token}?filename=image.png` }}
          title="Scan the QR code to authorize Raycaster"
        />
      )}
      {!complete && signInError && <List.EmptyView icon={Icon.XMarkCircle} title="Timed out trying to sign in" />}
    </List>
  );
}

Now, this might seem a little daunting, but let’s walk through each piece and what we’re trying to do. First, we’ll import dependencies, and inside our Command() component, we’ll add some React state (which we’ll get to soon). Then, we have just one function here called createSigner(). The first thing it’s going to do is check if we already have a stored local key called signerKey. If we do, then we set complete to true and return. Otherwise, we continue with the rest of the code, which includes an API request to our keys server. The server will return us a JSON object that looks like this:

{
    "deepLinkUrl": "farcaster://signed-key-request?token=<POLLING_TOKEN>",
    "pollingToken": "<POLLING_TOKEN>",
    "privateKey": "<PRIVATE_KEY>",
    "publicKey": "<PUBLIC_KEY>"
}
  • deepLinkUrl - Can be turned into a QR code or button on mobile to open up Warpcast to approve the signed key

  • pollingToken - Used in the /sign-in/poll endpoint to check on the status of whether the key has been approved

  • privateKey & publicKey - The signed keypair that we’ll store for sending casts

With this data, we’ll start a new API request to our server to poll this key request, waiting for our user to approve the key we just made. To give them access to approve this key, we’ll use setToken(keyRes.pollingToken) which then updates our handy UI component at the bottom:

{!complete && !signInError && (
  <List.EmptyView
    icon={{ source: `https://api.farcasterkeys.com/qr/${token}?filename=image.png` }}
    title="Scan the QR code to authorize Raycaster"
  />
)}

You might notice that the icon source is our same keys server, but with a special endpoint that returns a QR code image based on the polling token provided - pretty nifty! While that user is scanning the code and approving it in Warpcast, our createSigner() function will continue to poll token to check the status every two seconds. From there we have two outcomes: A) The request takes too long and times out, and we throw an error to show the user in the UI, or B) The signed key request is approved by the user and we save both the privateKey and fid to local storage and make the UI reflect that the extension has been authorized. Now we have what we need to start sending casts.

Send Cast

Before we start updating the code for the send-cast command, we’ll make one more small change to our package.json by adding this code:

{
  "preferences": [
    {
      "name": "PINATA_JWT",
      "description": "Your Pinata API key JWT which can be created at https://app.pinata.cloud/developers",
      "type": "password",
      "required": false,
      "title": "Pinata API JWT"
    },
    {
      "name": "GATEWAY",
      "description": "Paste in your Dedicated Gateway or public gateway in the following format: gateway.pinata.cloud",
      "type": "textfield",
      "required": false,
      "title": "IPFS Gateway",
      "placeholder": "gateway.pinata.cloud"
    }
  ]
}

What you see here is a special field provided by Raycast called Preferences. It allows users to securely make preferences or add API keys to be used in the extension. In ours, we’ll make two, a Pinata API JWT and a Gateway field. Both of them are optional but will enable uploads to IPFS that can be used as image attachments for casts!

Back in the send-cast.tsx file, let’s update the code with the following.

import { useState } from "react";
import {
  LocalStorage,
  Form,
  ActionPanel,
  Action,
  showToast,
  getPreferenceValues,
  Toast,
  openExtensionPreferences,
  Detail,
  showHUD,
  PopToRootType,
} from "@raycast/api";
import { Message, NobleEd25519Signer, CastAddBody, makeCastAdd } from "@farcaster/core";
import { Values, ChannelResult, FileUploadResult } from "./types";
import { hexToBytes } from "@noble/hashes/utils";
import fetch from "node-fetch";
import fs from "fs";
import FormData from "form-data";

interface Preferences {
  PINATA_JWT?: string;
  GATEWAY?: string;
}

const preferences = getPreferenceValues<Preferences>();

const JWT = preferences.PINATA_JWT;
const GATEWAY = preferences.GATEWAY;

export default function Command() {
  const [lengthError, setLengthError] = useState<string | undefined>();
  const [imageUploadError, setImageUploadError] = useState<boolean>(false);
  const markdown = `

# Pinata API key either missing or incorrect. 

Press Enter then update the key on the right side of the prefernce pane
`;

  function dropLengthErrorIfNeeded() {
    if (lengthError && lengthError.length > 0) {
      setLengthError(undefined);
    }
  }

  async function uploadFile(selectedFile: string) {
    if (!preferences.PINATA_JWT) {
      await showToast({
        style: Toast.Style.Failure,
        title: "Files require a Pinata API Key",
      });
      setImageUploadError(true);
    }

    await showToast({ style: Toast.Style.Animated, title: "Uploading File..." });

    try {
      const data = new FormData();

      const file = fs.createReadStream(selectedFile);

      data.append("file", file);

      const uploadReq = await fetch("https://api.pinata.cloud/pinning/pinFileToIPFS", {
        method: "POST",
        headers: {
          Authorization: `Bearer ${JWT}`,
        },
        body: data,
      });

      if (!uploadReq.ok) {
        throw new Error("Problem uploading file");
      }
      const uploadRes = (await uploadReq.json()) as FileUploadResult;

      const headersReq = await fetch(`https://${GATEWAY}/ipfs/${uploadRes.IpfsHash}`, {
        method: "HEAD",
      });

      const fileExtension = selectedFile.split('.').pop();

      const link = `https://${GATEWAY}/ipfs/${uploadRes.IpfsHash}?filename=image.${fileExtension}`;

      return link;
    } catch (error) {
      console.log("Problem uploading files", error);
      throw new Error("Problem Uploading File");
    }
  }

  async function sendCast(
    message: string,
    parentUrl?: string | undefined,
    embed?: string | undefined,
    file?: string | undefined,
  ) {
    await showToast({ style: Toast.Style.Animated, title: "Sending Cast..." });
    const FID = await LocalStorage.getItem("fid");
    const SIGNER = (await LocalStorage.getItem("signerKey")) as string;

    if (!FID || !SIGNER) {
      throw new Error("Sign in first");
    }
    try {
      const dataOptions = {
        fid: Number(FID),
        network: 1,
      };

      const privateKeyBytes = hexToBytes(SIGNER.slice(2));
      const ed25519Signer = new NobleEd25519Signer(privateKeyBytes);

      const castBody: CastAddBody = {
        text: message,
        embeds: [],
        embedsDeprecated: [],
        mentions: [],
        mentionsPositions: [],
        parentUrl: parentUrl,
      };

      if (embed) {
        const embedUrl = { url: embed };
        castBody.embeds.push(embedUrl);
      }

      if (file) {
        const fileUrl = { url: file };
        castBody.embeds.push(fileUrl);
      }

      const castAddReq = await makeCastAdd(castBody, dataOptions, ed25519Signer);
      const castAdd = castAddReq._unsafeUnwrap();
      const messageBytes = Buffer.from(Message.encode(castAdd).finish());

      const castRequest = await fetch("https://hub.pinata.cloud/v1/submitMessage", {
        method: "POST",
        headers: { "Content-Type": "application/octet-stream" },
        body: messageBytes,
      });

      const castResult = await castRequest.json();
      return castResult;
    } catch (error) {
      console.log("problem sending cast:", error);
    }
  }

  async function handleSubmit(values: Values) {
    try {
      let channelUrl: string | undefined;
      let fileUrl: string | undefined;

      if (values.channel) {
        const channelReq = await fetch(`https://api.warpcast.com/v1/channel?channelId=${values.channel}`);

        if (!channelReq.ok) {
          throw new Error("Channel not found");
        }

        const channelRes = (await channelReq.json()) as ChannelResult;
        channelUrl = channelRes.result.channel.url;
      } else {
        channelUrl = undefined;
      }

      if (values.image && values.image.length > 0) {
        const upload = await uploadFile(values.image[0]);
        fileUrl = upload;
      } else {
        fileUrl = undefined;
      }

      const castRes = await sendCast(values.cast, channelUrl, values.embed, fileUrl);
      if (!castRes) {
        throw new Error("Trouble sending cast");
      }
      await showHUD("Cast Sent!", { clearRootSearch: true, popToRootType: PopToRootType.Immediate });
    } catch (error) {
      console.log(error);
      showToast({ title: "Problem sending cast", message: error as string, style: Toast.Style.Failure });
    }
  }

  if (imageUploadError) {
    return (
      <Detail
        markdown={markdown}
        actions={
          <ActionPanel>
            <Action title="Open Extension Preferences" onAction={openExtensionPreferences} />
          </ActionPanel>
        }
      />
    );
  }

  return (
    <Form
      actions={
        <ActionPanel>
          <Action.SubmitForm title="Send Cast" onSubmit={handleSubmit} />
          <Action
            shortcut={{ modifiers: ["cmd", "shift"], key: "p" }}
            title="Setup Pinata for Images"
            onAction={openExtensionPreferences}
          />
        </ActionPanel>
      }
    >
      <Form.Description text="At least one field should be used to send a cast" />
      <Form.TextArea
        id="cast"
        title="Cast"
        placeholder="Type you main cast here"
        error={lengthError}
        onChange={dropLengthErrorIfNeeded}
        onBlur={(event) => {
          const value = event.target.value;
          if (value && value.length > 320) {
            setLengthError("Exceeds 320 character maximum");
          } else {
            dropLengthErrorIfNeeded();
          }
        }}
      />
      <Form.TextField id="embed" title="Embed" placeholder="Past a link here" />
      <Form.TextField id="channel" title="Channel" placeholder="raycast" />
      <Form.Separator />
      <Form.FilePicker
        id="image"
        title="Image"
        canChooseDirectories={false}
        allowMultipleSelection={false}
        info="Requires Pinata API key, run Cmd + Shift + Enter"
        storeValue={false}
      />
    </Form>
  );
}

Again, it’s a lot of code, but we’ll break it down piece by piece. At the top, we have our imports from different libraries, as well as an interface for Preferences. Followed closely behind is our preferences object where we use the Raycast API to get the Pinata JWT and Gateway fields provided by the user.

Inside the Command() component, we’ll start by creating some error states, and an error message to pop up if someone tries to upload a file without first entering their Pinata API key. We also have a special error function designed by Raycast to show up if people exceed 320 characters in the text field, which we’ll get to momentarily.

Next, we have our uploadFile function, which takes in a path to a local file on the user’s computer. We then follow the patterns outlined in the Pinata Docs for pinning a file via API. We use fs to read the file, attach it to FormData, and then submit the API request to upload. Once we get the CID back, we can create a link using our gateway by appending the file extension from the selectedFile path. With that link, we can then use it in our cast body!

Moving on to the next and primary function, sendCast, we take in several parameters, including the message or main text of the cast, a parentUrl for channels, embed for links, and file for image uploads. From there, we retrieve the local storage items FID and SIGNER from our previous Authorize command and trigger an error if someone tries to send a cast without those. We then follow the pattern of sending a cast as demonstrated in this post, with the exception that if we receive a file or a link, we push it to the embeds array. We format the request using makeCastAdd, convert it into bytes, and then send the cast request to the Pinata Hub.

Now, the final function handleSubmit brings it all together! With this function, we can use the automatic values passed down through the Form component by Raycast, which is a really easy way to handle state. One of the challenges with posting to a channel is that we need the full URL, and there are too many channels to try live filtering without better infrastructure or a CDN. Instead, we take a simpler approach by making a request to the Warpcast API for a channel by name, such as diet-coke, and use the response as the channel URL. Additionally, if we happen to have a file passed in through the form, we use our uploadFile function and assign the link to fileUrl. Finally, we send our cast, passing in all our values and returning either a success or error message.

Lastly, if you look in the UI section, you can see how much we've been able to achieve with very little code, thanks to Raycast’s UI library. Everything is handled, including form validation if there are too many characters in the text field, or returning a separate component if someone tries to upload a file without a Pinata API key.

Once you’ve completed your extension, follow this guide by Raycast to go through the steps to publish it on their store. From my experience, it’s really helpful to run npm run build frequently to catch any errors, as well as npx ray lint --fix to handle formatting issues. Once you have all the details done, such as filling out the package.json fields and creating an icon, you can run npx ray publish, which will create a fork and branch for your extension and even open the PR to submit. In the PR, add details about your extension and include a video explaining what it does to help ensure it gets approved faster. After a little back and forth with the team, you should be all set!

Raycaster is now available and can be downloaded here

This will spin up a dev server and load the extension locally and allow you to use it whenever you want! At this point you can kill the dev server and the extension will stay available.

This is just the beginning of what you can do with Farcaster, Raycast, and Pinata. We can’t wait to see what you build! Happy Pinning!

Pinata logo
Subscribe to Pinata and never miss a post.
  • Loading comments...