Skip to main content

始める前に

今回は、プロフィール画像の取得方法として
  • Airstack API を利用する方法(難易度*1:★★★☆☆)
  • Frames Context を利用する方法(難易度*1:★☆☆☆☆)
をそれぞれ説明します。 ※1)プログラミング初心者における難易度のレベル

1. Airstack API を利用する方法

この方法では、Lesson 2 のソースコードを元に必要な処理を追加していきますので、まずは Lesson 2 のソースコードに 修正後のコード の内容に従い修正を加え、Airstack API から取得したデータを利用できる状態にします。

修正前のコード

/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  let error: string | null = null;
  let isLoading = false;

  const fetchUserData = async (fid: string) => {
    isLoading = true;
    try {
      const airstackUrl = `${appURL()}/api?userId=${encodeURIComponent(fid)}`;
      const airstackResponse = await fetch(airstackUrl);
      if (!airstackResponse.ok) {
        throw new Error(`Airstack HTTP error! status: ${airstackResponse.status}`);
      }
    } catch (err) {
      console.error("Error fetching data:", err);
      error = (err as Error).message;
    } finally {
      isLoading = false;
    }
  };

  let fid: string | null = null;
  if (ctx.message?.requesterFid) {
    fid = ctx.message.requesterFid.toString();
    console.log("Using requester FID:", fid);
  } else {
    console.log("No ctx.url available");
  }
  console.log("Final FID used:", fid);

  if (fid) {
    await Promise.all([fetchUserData(fid)]);
  }

  return {
    image: `${appURL()}/02.png`,
    buttons: [
      <Button action="post" target="/">
        最初のページへ
      </Button>,
    ],
  };

});

export const GET = handleRequest;
export const POST = handleRequest;

修正後のコード

/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  interface UserData {
    fid: string;
    profileImageUrl: string;
  }

  let userData: UserData | null = null;
  let error: string | null = null;
  let isLoading = false;

  const fetchUserData = async (fid: string) => {
    isLoading = true;
    try {
      const airstackUrl = `${appURL()}/api?userId=${encodeURIComponent(fid)}`;
      const airstackResponse = await fetch(airstackUrl);
      if (!airstackResponse.ok) {
        throw new Error(`Airstack HTTP error! status: ${airstackResponse.status}`);
      }
      const airstackData = await airstackResponse.json();
      if (airstackData.userData.Socials.Social && airstackData.userData.Socials.Social.length > 0) {
        const social = airstackData.userData.Socials.Social[0];
        userData = {
          fid: social.userId || "",
          profileImageUrl: social.profileImage || "",
        };
      } else {
        throw new Error("No user data found");
      }
    } catch (err) {
      console.error("Error fetching data:", err);
      error = (err as Error).message;
    } finally {
      isLoading = false;
    }
  };

  let fid: string | null = null;
  if (ctx.message?.requesterFid) {
    fid = ctx.message.requesterFid.toString();
    console.log("Using requester FID:", fid);
  } else {
    console.log("No ctx.url available");
  }
  console.log("Final FID used:", fid);

  const shouldFetchData = fid && (!userData || (userData as UserData).fid !== fid);
  if (shouldFetchData && fid) {
    await Promise.all([fetchUserData(fid)]);
  }

  return {
    image: `${appURL()}/02.png`,
    buttons: [
      <Button action="post" target="/">
        Reset
      </Button>,
    ],
  };
});

export const GET = handleRequest;
export const POST = handleRequest;
次に、変数 profileImageUrl に対して、Airstack API からデータを取得できた場合は userData.profileImageUrl の値を、Airstack API からデータを取得できなかったは ${appURL()}/unavailable.png を値として定義し、image: の値として設定します。
/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  interface UserData {
    fid: string;
    profileImageUrl: string;
  }

  let userData: UserData | null = null;
  let error: string | null = null;
  let isLoading = false;

  const fetchUserData = async (fid: string) => {
    isLoading = true;
    try {
      const airstackUrl = `${appURL()}/api?userId=${encodeURIComponent(fid)}`;
      const airstackResponse = await fetch(airstackUrl);
      if (!airstackResponse.ok) {
        throw new Error(`Airstack HTTP error! status: ${airstackResponse.status}`);
      }
      const airstackData = await airstackResponse.json();
      if (airstackData.userData.Socials.Social && airstackData.userData.Socials.Social.length > 0) {
        const social = airstackData.userData.Socials.Social[0];
        userData = {
          fid: social.userId || "",
          profileImageUrl: social.profileImage || "",
        };
      } else {
        throw new Error("No user data found");
      }
    } catch (err) {
      console.error("Error fetching data:", err);
      error = (err as Error).message;
    } finally {
      isLoading = false;
    }
  };

  let fid: string | null = null;
  if (ctx.message?.requesterFid) {
    fid = ctx.message.requesterFid.toString();
    console.log("Using requester FID:", fid);
  } else {
    console.log("No ctx.url available");
  }
  console.log("Final FID used:", fid);

  const shouldFetchData = fid && (!userData || (userData as UserData).fid !== fid);
  if (shouldFetchData && fid) {
    await Promise.all([fetchUserData(fid)]);
  }

  const profileImageUrl = userData ? (userData as UserData).profileImageUrl : `${appURL()}/unavailable.png`;

  return {
    image: profileImageUrl,
    buttons: [
      <Button action="post" target="/">
        Reset
      </Button>,
    ],
  };
});

export const GET = handleRequest;
export const POST = handleRequest;
最後に、今回はレッスンはプロフィール画像を使ったサンプルのため、フレームの画像の比率を 1:1 に設定します。
/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  interface UserData {
    fid: string;
    profileImageUrl: string;
  }

  let userData: UserData | null = null;
  let error: string | null = null;
  let isLoading = false;

  const fetchUserData = async (fid: string) => {
    isLoading = true;
    try {
      const airstackUrl = `${appURL()}/api?userId=${encodeURIComponent(fid)}`;
      const airstackResponse = await fetch(airstackUrl);
      if (!airstackResponse.ok) {
        throw new Error(`Airstack HTTP error! status: ${airstackResponse.status}`);
      }
      const airstackData = await airstackResponse.json();
      if (airstackData.userData.Socials.Social && airstackData.userData.Socials.Social.length > 0) {
        const social = airstackData.userData.Socials.Social[0];
        userData = {
          fid: social.userId || "",
          profileImageUrl: social.profileImage || "",
        };
      } else {
        throw new Error("No user data found");
      }
    } catch (err) {
      console.error("Error fetching data:", err);
      error = (err as Error).message;
    } finally {
      isLoading = false;
    }
  };

  let fid: string | null = null;
  if (ctx.message?.requesterFid) {
    fid = ctx.message.requesterFid.toString();
    console.log("Using requester FID:", fid);
  } else {
    console.log("No ctx.url available");
  }
  console.log("Final FID used:", fid);

  const shouldFetchData = fid && (!userData || (userData as UserData).fid !== fid);
  if (shouldFetchData && fid) {
    await Promise.all([fetchUserData(fid)]);
  }

  const profileImageUrl = userData ? (userData as UserData).profileImageUrl : `${appURL()}/unavailable.png`;

  return {
    image: profileImageUrl,
    imageOptions: {
      aspectRatio: "1:1",
    },
    buttons: [
      <Button action="post" target="/">
        Reset
      </Button>,
    ],
  };
});

export const GET = handleRequest;
export const POST = handleRequest;
問題なくプロフール画像が取得できていれば次のページにボタンをクリックしたユーザーのプロフィール画像が表示されるはずです。

2. Frames Context を利用する方法

この方法は Airstack API のデータを用いないため API キーの必要がなく、比較的簡単に実装できますが、Airstack API からデータを取得する処理を残しておかないと Airstack API への接続が発生せず、フレームの製作者を識別できないため、Moxie の報酬対象外となりますのでご注意ください。
この方法では、Airstack API に関する処理やファイルが必要ないため、Lesson 1 のソースコードを元に必要な処理を追加していきます。 まずは、Frames Context に含まれるデータがどのような内容か、どのデータを取得する必要があるのか確認するため、ctx の内容をコンソールログに出力します。
/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  console.log("Context:", ctx);

  return {
    image: `${appURL()}/02.png`,
    buttons: [
      <Button action="post" target="/">
        Reset
      </Button>,
    ],
  };
});

export const GET = handleRequest;
export const POST = handleRequest;
修正が終わったら npm run dev でローカルサーバーを起動し、フレームのボタンをクリックしてコンソールログに表示されるデータを確認します。
おそらく以下のようなログが表示されるはずです。
Context: {
  basePath: '/frames',
  initialState: undefined,
  request: Request {
    method: 'POST',
    url: 'http://localhost:3000/frames/reveal?__bi=1-p',
    headers: Headers {
      host: 'localhost:3000',
      connection: 'keep-alive',
      'content-type': 'application/json',
      accept: '*/*',
      'accept-language': '*',
      'sec-fetch-mode': 'cors',
      'user-agent': 'node',
      'accept-encoding': 'gzip, deflate',
      'content-length': '802',
      'x-forwarded-host': 'localhost:3000',
      'x-forwarded-port': '3000',
      'x-forwarded-proto': 'http',
      'x-forwarded-for': '::1'
    },
    destination: '',
    referrer: 'about:client',
    referrerPolicy: '',
    mode: 'cors',
    credentials: 'same-origin',
    cache: 'default',
    redirect: 'follow',
    integrity: '',
    keepalive: false,
    isReloadNavigation: false,
    isHistoryNavigation: false,
    signal: AbortSignal { aborted: false }
  },
  url: URL {
    href: 'http://localhost:3000/frames/reveal?__bi=1-p',
    origin: 'http://localhost:3000',
    protocol: 'http:',
    username: '',
    password: '',
    host: 'localhost:3000',
    hostname: 'localhost',
    port: '3000',
    pathname: '/frames/reveal',
    search: '?__bi=1-p',
    searchParams: URLSearchParams { '__bi' => '1-p' },
    hash: ''
  },
  baseUrl: URL {
    href: 'http://localhost:3000/frames',
    origin: 'http://localhost:3000',
    protocol: 'http:',
    username: '',
    password: '',
    host: 'localhost:3000',
    hostname: 'localhost',
    port: '3000',
    pathname: '/frames',
    search: '',
    searchParams: URLSearchParams {},
    hash: ''
  },
  stateSigningSecret: undefined,
  walletAddress: [AsyncFunction: walletAddress],
  debug: false,
  __debugInfo: {},
  pressedButton: { action: 'post', index: 1 },
  searchParams: { __bi: '1-p' },
  message: {
    url: 'http://localhost:3000/',
    buttonIndex: 1,
    castId: { fid: 1, hash: '0x0000000000000000000000000000000000000000' },
    inputText: '',
    requesterFid: 291942,
    state: '',
    connectedAddress: undefined,
    address: undefined,
    transactionId: undefined,
    isValid: true,
    casterFollowsRequester: false,
    requesterFollowsCaster: false,
    likedCast: false,
    recastedCast: false,
    requesterVerifiedAddresses: [],
    requesterCustodyAddress: '',
    requesterUserData: {
      profileImage: 'https://i.seadn.io/gae/N1SdZkJsgf3erECLe24BL6BKIvvlAsqlUhG65nT0GqHlyjAa-IRgiYzLHUkf7mtad_ZZKO_QrqBUq6IMf_Rku-zTrlaB2PjC6WA?w=500&auto=format',
      displayName: 'FREAK',
      username: 'djfreak.eth',
      bio: 'DJ, Graphic Designer, Crypto Investor, Art Collector.\n' +
        'Host: /dance-music\n' +
        'Council Member: /sonata\n' +
        'Moderator: /conbini\n' +
        'Spotify Playlist: https://linktr.ee/djfreak',
      location: ''
    },
    walletAddress: [Function: walletAddress]
  },
  clientProtocol: { id: 'farcaster', version: 'vNext' },
  state: undefined
}
次に、Frames Context のデータの中に必要なデータがあるか確認をします。 以下はデータの構造をより分かりやすくするために不必要なデータを削除し、必要なデータをハイライトしたものです。
Context: {
  basePath: '/frames',
  message: {
    url: 'http://localhost:3000/',
    buttonIndex: 1,
    castId: { fid: 1, hash: '0x0000000000000000000000000000000000000000' },
    inputText: '',
    requesterFid: 291942,
    ========== 省略 ==========
    requesterUserData: {
      profileImage: 'https://i.seadn.io/gae/N1SdZkJsgf3erECLe24BL6BKIvvlAsqlUhG65nT0GqHlyjAa-IRgiYzLHUkf7mtad_ZZKO_QrqBUq6IMf_Rku-zTrlaB2PjC6WA?w=500&auto=format',
      displayName: 'FREAK',
      username: 'djfreak.eth',
      bio: 'DJ, Graphic Designer, Crypto Investor, Art Collector.\n' +
        'Host: /dance-music\n' +
        'Council Member: /sonata\n' +
        'Moderator: /conbini\n' +
        'Spotify Playlist: https://linktr.ee/djfreak',
      location: ''
    }
  }
}
今回のケースでは message > requesterUserData > profileImage の内容が必要なはずなので、画像のURLとして使えるように profileImage の値を変数 profileImageUrl に代入します。
/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  console.log("Context:", ctx);

  const profileImageUrl = ctx.message?.requesterUserData?.profileImage || `${appURL()}/unavailable.png`;

  return {
    image: `${appURL()}/02.png`,
    buttons: [
      <Button action="post" target="/">
        Reset
      </Button>,
    ],
  };
});

export const GET = handleRequest;
export const POST = handleRequest;
あとは変数 profileImageUrlimage: の値として設定します。
/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  console.log("Context:", ctx);

  const profileImageUrl = ctx.message?.requesterUserData?.profileImage || `${appURL()}/unavailable.png`;

  return {
    image: profileImageUrl,
    buttons: [
      <Button action="post" target="/">
        Reset
      </Button>,
    ],
  };
});

export const GET = handleRequest;
export const POST = handleRequest;
最後に、この方法の場合もフレームの画像の比率を 1:1 に設定します。
/app/frames/reveal/route.tsx
import { Button } from "frames.js/next";
import { frames } from "app/frames/frames";
import { appURL } from "app/utils";

const handleRequest = frames(async (ctx) => {

  console.log("Context:", ctx);

  const profileImageUrl = ctx.message?.requesterUserData?.profileImage || `${appURL()}/unavailable.png`;

  return {
    image: profileImageUrl,
    imageOptions: {
      aspectRatio: "1:1",
    },
    buttons: [
      <Button action="post" target="/">
        Reset
      </Button>,
    ],
  };
});

export const GET = handleRequest;
export const POST = handleRequest;
問題なくプロフール画像が取得できていれば次のページにボタンをクリックしたユーザーのプロフィール画像が表示されるはずです。