diff --git a/lib/types/farcaster-types.js b/lib/types/farcaster-types.js index 81f416d8f..701d24b00 100644 --- a/lib/types/farcaster-types.js +++ b/lib/types/farcaster-types.js @@ -1,78 +1,86 @@ // @flow // This is a message that the rendered webpage // (landing/connect-farcaster.react.js) uses to communicate back // to the React Native WebView that is rendering it // (native/components/farcaster-web-view.react.js) export type FarcasterWebViewMessage = | { +type: 'farcaster_url', +url: string, } | { +type: 'farcaster_data', +fid: string, }; export type NeynarUser = { +fid: number, +username: string, +pfp_url: string, ... }; export type NeynarUserWithViewerContext = $ReadOnly<{ ...NeynarUser, +viewerContext: { +following: boolean, }, ... }>; export type NeynarChannel = { +id: string, +name: string, +follower_count: number, +lead: { +fid: number, ... }, +image_url: string, +description: string, ... }; +export type NeynarCast = { + +hash: string, + +thread_hash: string, + +text: string, + +author: NeynarUser, + ... +}; + export type NeynarWebhookCastAuthor = { +object: 'user', +fid: number, +custody_address: string, +username: string, +display_name: string, +pfp_url: string, ... }; export type NeynarWebhookCastCreatedData = { +object: 'cast', +hash: string, +thread_hash: string, +text: string, +channel?: ?NeynarWebhookChannel, +parent_hash?: ?string, +author: NeynarWebhookCastAuthor, ... }; export type NeynarWebhookChannel = { +id: string, +name: string, +image_url: string, ... }; export type NeynarWebhookCastCreatedEvent = { +created_at: number, +type: 'cast.created', +data: NeynarWebhookCastCreatedData, ... }; diff --git a/lib/utils/neynar-client.js b/lib/utils/neynar-client.js index 22b281991..ebc74c6fe 100644 --- a/lib/utils/neynar-client.js +++ b/lib/utils/neynar-client.js @@ -1,330 +1,362 @@ // @flow import invariant from 'invariant'; import { getMessageForException } from './errors.js'; -import type { NeynarChannel, NeynarUser } from '../types/farcaster-types.js'; +import type { + NeynarChannel, + NeynarUser, + NeynarCast, +} from '../types/farcaster-types.js'; type FetchRelevantFollowersResponse = { +all_relevant_followers_dehydrated: $ReadOnlyArray<{ +user: { +fid: number, ... }, ... }>, ... }; type FetchFarcasterChannelsResponse = { +channels: $ReadOnlyArray, +next: { +cursor: ?string, }, }; type FetchUsersResponse = { +users: $ReadOnlyArray, }; type FetchFarcasterChannelInfoResponse = { +channel: NeynarChannel, }; +type FetchFarcasterCastByHashResponse = { + +cast: NeynarCast, +}; + export type FarcasterUser = { +username: string, +pfpURL: string, }; const neynarBaseURL = 'https://api.neynar.com/'; const neynarURLs = { '1': `${neynarBaseURL}v1/farcaster/`, '2': `${neynarBaseURL}v2/farcaster/`, }; function getNeynarURL( apiVersion: string, apiCall: string, params: { [string]: string }, ): string { const neynarURL = neynarURLs[apiVersion]; invariant( neynarURL, `could not find Neynar URL for apiVersion ${apiVersion}`, ); return `${neynarURL}${apiCall}?${new URLSearchParams(params).toString()}`; } const fetchFollowedChannelsLimit = 100; const fetchChannelsLimit = 50; class NeynarClient { apiKey: string; constructor(apiKey: string) { this.apiKey = apiKey; } // We're using the term "friend" for a bidirectional follow async fetchFriendFIDs(fid: string): Promise { const params: { [string]: string } = { target_fid: fid, viewer_fid: fid, }; const url = getNeynarURL('2', 'followers/relevant', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchRelevantFollowersResponse = await response.json(); const { all_relevant_followers_dehydrated } = json; return all_relevant_followers_dehydrated.map(follower => follower.user.fid.toString(), ); } catch (error) { console.log( 'Failed to fetch friend FIDs:', getMessageForException(error) ?? 'unknown', ); throw error; } } async fetchFollowedFarcasterChannelsWithFilter( fid: string, filterFn: (channel: NeynarChannel) => boolean, ): Promise { const farcasterChannels = []; let paginationCursor = null; do { const params: { [string]: string } = { fid, limit: fetchFollowedChannelsLimit.toString(), ...(paginationCursor ? { cursor: paginationCursor } : null), }; const url = getNeynarURL('2', 'user/channels', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelsResponse = await response.json(); const { channels, next } = json; channels.forEach(channel => { if (filterFn(channel)) { farcasterChannels.push(channel); } }); paginationCursor = next.cursor; } catch (error) { console.log( 'Failed to fetch followed Farcaster channels:', getMessageForException(error) ?? 'unknown', ); throw error; } } while (paginationCursor); return farcasterChannels; } fetchFollowedFarcasterChannels(fid: string): Promise { return this.fetchFollowedFarcasterChannelsWithFilter(fid, () => true); } async fetchFarcasterChannelByID(channelID: string): Promise { const params: { [string]: string } = { q: channelID, }; const url = getNeynarURL('2', 'channel/search', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelsResponse = await response.json(); const { channels } = json; for (const channel of channels) { if (channel.id.toLowerCase() === channelID.toLowerCase()) { return channel; } } return null; } catch (error) { console.log( `Failed to search Farcaster channel by ID:`, getMessageForException(error) ?? 'unknown', ); throw error; } } async getFarcasterUsers( fids: $ReadOnlyArray, ): Promise> { const fidsLeft = [...fids]; const results: Array = []; do { // Neynar API allows querying 100 at a time const batch = fidsLeft.splice(0, 100); const url = getNeynarURL('2', 'user/bulk', { fids: batch.join(',') }); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchUsersResponse = await response.json(); const { users } = json; const neynarUserMap = new Map(); for (const neynarUser of users) { neynarUserMap.set(neynarUser.fid, neynarUser); } for (const fid of batch) { const neynarUser = neynarUserMap.get(parseInt(fid)); results.push( neynarUser ? { username: neynarUser.username, pfpURL: neynarUser.pfp_url } : null, ); } } catch (error) { console.log( 'Failed to fetch Farcaster usernames:', getMessageForException(error) ?? 'unknown', ); throw error; } } while (fidsLeft.length > 0); return results; } async getAllChannels(): Promise> { const farcasterChannels = []; let paginationCursor = null; do { const params: { [string]: string } = { limit: fetchChannelsLimit.toString(), ...(paginationCursor ? { cursor: paginationCursor } : null), }; const url = getNeynarURL('2', 'channel/list', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelsResponse = await response.json(); const { channels, next } = json; channels.forEach(channel => { farcasterChannels.push(channel); }); paginationCursor = next.cursor; } catch (error) { console.log( 'Failed to fetch all Farcaster channels:', getMessageForException(error) ?? 'unknown', ); throw error; } } while (paginationCursor); return farcasterChannels; } async fetchFarcasterChannelInfo( channelID: string, viewerFID: string, ): Promise { const params: { [string]: string } = { id: channelID, type: 'id', viewer_fid: viewerFID, }; const url = getNeynarURL('2', 'channel', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelInfoResponse = await response.json(); return json.channel; } catch (error) { console.log( 'Failed to fetch Farcaster channel info:', getMessageForException(error) ?? 'unknown', ); throw error; } } async checkIfCurrentUserFIDIsValid(fid: string): Promise { const url = getNeynarURL('2', 'user/bulk', { fids: fid }); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); return response.ok; } catch (error) { console.log( 'Failed to check if current user FID is valid:', getMessageForException(error) ?? 'unknown', ); throw error; } } + + async fetchFarcasterCastByHash(hash: string): Promise { + const url = getNeynarURL('2', 'cast', { identifier: hash, type: 'hash' }); + + try { + const response = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + api_key: this.apiKey, + }, + }); + + const json: FetchFarcasterCastByHashResponse = await response.json(); + + return json.cast; + } catch (error) { + console.log( + 'Failed to fetch Farcaster cast:', + getMessageForException(error) ?? 'unknown', + ); + throw error; + } + } } export { NeynarClient };