diff --git a/keyserver/src/responders/farcaster-webhook-responders.js b/keyserver/src/responders/farcaster-webhook-responders.js index 209de0083..d2a0a7385 100644 --- a/keyserver/src/responders/farcaster-webhook-responders.js +++ b/keyserver/src/responders/farcaster-webhook-responders.js @@ -1,337 +1,337 @@ // @flow import { createHmac } from 'crypto'; import type { $Request } from 'express'; import invariant from 'invariant'; import bots from 'lib/facts/bots.js'; import { inviteLinkURL } from 'lib/facts/links.js'; import { extractKeyserverIDFromID } from 'lib/keyserver-conn/keyserver-call-utils.js'; import { createSidebarThreadName } from 'lib/shared/sidebar-utils.js'; import { type NeynarWebhookCastCreatedEvent } from 'lib/types/farcaster-types.js'; import { messageTypes } from 'lib/types/message-types-enum.js'; import { threadTypes } from 'lib/types/thread-types-enum.js'; import { type NewThreadResponse } from 'lib/types/thread-types.js'; import { neynarWebhookCastCreatedEventValidator } from 'lib/types/validators/farcaster-webhook-validators.js'; import { ServerError } from 'lib/utils/errors.js'; import { assertWithValidator } from 'lib/utils/validation-utils.js'; import { getFarcasterChannelTagBlob, createOrUpdateFarcasterChannelTag, farcasterChannelTagBlobValidator, } from '../creators/farcaster-channel-tag-creator.js'; import { createOrUpdatePublicLink } from '../creators/invite-link-creator.js'; import createMessages from '../creators/message-creator.js'; import { createThread } from '../creators/thread-creator.js'; import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { createBotViewer } from '../session/bots.js'; import { createScriptViewer } from '../session/scripts.js'; import { changeRole, commitMembershipChangeset, } from '../updaters/thread-permission-updaters.js'; import { updateRole, joinThread } from '../updaters/thread-updaters.js'; import { thisKeyserverAdmin, thisKeyserverID } from '../user/identity.js'; import { getFarcasterBotConfig } from '../utils/farcaster-bot.js'; import { getVerifiedUserIDForFID } from '../utils/farcaster-utils.js'; import { neynarClient, fcCache } from '../utils/fc-cache.js'; import { getNeynarConfig } from '../utils/neynar-utils.js'; const taggedCommFarcasterInputValidator = neynarWebhookCastCreatedEventValidator; const threadHashTagRegex = /\B#createathread\b/i; const noChannelCommunityID = '80887273'; const { commbot } = bots; const commbotViewer = createBotViewer(commbot.userID); async function createCastSidebar( sidebarCastHash: string, channelFarcasterID: ?string, channelCommunityID: string, taggerUserID: ?string, ): Promise { const sidebarCast = await neynarClient?.fetchFarcasterCastByHash(sidebarCastHash); if (!sidebarCast) { return null; } const { author: { username: castAuthor }, text: castText, } = sidebarCast; const warpcastLink = `https://warpcast.com/${castAuthor}/${sidebarCastHash}`; const saidText = `[said](${warpcastLink})`; const channelText = channelFarcasterID ? ` in channel /${channelFarcasterID}` : ''; const quoteText = castText .split('\n') .map(line => `> ${line}`) .join('\n'); const messageText = `${castAuthor} ${saidText}${channelText}:\n${quoteText}`; let viewer = commbotViewer; if (taggerUserID) { viewer = createScriptViewer(taggerUserID); await joinThread(viewer, { threadID: channelCommunityID, }); } else { const changeset = await changeRole( channelCommunityID, [commbot.userID], -1, ); await commitMembershipChangeset(viewer, changeset); } const [{ id: messageID }] = await createMessages(viewer, [ { type: messageTypes.TEXT, threadID: channelCommunityID, creatorID: viewer.id, time: Date.now(), text: messageText, }, ]); invariant( messageID, 'message returned from createMessages always has ID set', ); const sidebarThreadName = createSidebarThreadName(messageText); const response = await createThread( viewer, { type: threadTypes.SIDEBAR, parentThreadID: channelCommunityID, name: sidebarThreadName, sourceMessageID: messageID, }, { addViewerAsGhost: !taggerUserID, }, ); return response; } async function createTaggedFarcasterCommunity( channelID: string, taggerUserID: ?string, ): Promise { const keyserverAdminPromise = thisKeyserverAdmin(); const neynarChannel = await fcCache?.getFarcasterChannelForChannelID(channelID); if (!neynarChannel) { throw new ServerError('channel_not_found'); } const leadFID = neynarChannel.lead.fid.toString(); const [leadUserID, keyserverAdmin] = await Promise.all([ getVerifiedUserIDForFID(leadFID), keyserverAdminPromise, ]); const communityAdminID = leadUserID ? leadUserID : taggerUserID || keyserverAdmin.id; const initialMemberIDs = [ ...new Set([leadUserID, taggerUserID, communityAdminID].filter(Boolean)), ]; const newThreadResponse = await createThread( commbotViewer, { type: threadTypes.COMMUNITY_ROOT, name: neynarChannel.name, initialMemberIDs, }, { forceAddMembers: true, addViewerAsGhost: true, }, ); const { newThreadID } = newThreadResponse; const fetchThreadResult = await fetchServerThreadInfos({ threadID: newThreadResponse.newThreadID, }); const threadInfo = fetchThreadResult.threadInfos[newThreadID]; if (!threadInfo) { throw new ServerError('fetch_failed'); } const adminRoleID = Object.keys(threadInfo.roles).find( roleID => threadInfo.roles[roleID].name === 'Admins', ); if (!adminRoleID) { throw new ServerError('community_missing_admin'); } await updateRole(commbotViewer, { threadID: newThreadID, memberIDs: [communityAdminID], role: adminRoleID, }); await createOrUpdateFarcasterChannelTag(commbotViewer, { commCommunityID: newThreadResponse.newThreadID, farcasterChannelID: channelID, }); return newThreadResponse; } async function verifyNeynarWebhookSignature( signature: string, event: NeynarWebhookCastCreatedEvent, ): Promise { const neynarSecret = await getNeynarConfig(); if (!neynarSecret?.neynarWebhookSecret) { throw new ServerError('missing_webhook_secret'); } const hmac = createHmac('sha512', neynarSecret.neynarWebhookSecret); hmac.update(JSON.stringify(event)); return hmac.digest('hex') === signature; } async function taggedCommFarcasterResponder(req: $Request): Promise { const { body } = req; const event = assertWithValidator(body, taggedCommFarcasterInputValidator); const { author: { fid: eventTaggerFID }, text: eventText, channel: eventChannel, } = event.data; const foundCreateThreadHashTag = threadHashTagRegex.test(eventText); if (!foundCreateThreadHashTag) { return; } const neynarConfigPromise = getNeynarConfig(); const taggerUserIDPromise = getVerifiedUserIDForFID( eventTaggerFID.toString(), ); const signature = req.header('X-Neynar-Signature'); if (!signature) { throw new ServerError('missing_neynar_signature'); } const [isValidSignature, farcasterBotConfig] = await Promise.all([ verifyNeynarWebhookSignature(signature, event), getFarcasterBotConfig(), ]); if (!isValidSignature) { throw new ServerError('invalid_webhook_signature'); } const eventChannelID = eventChannel?.id; const isAuthoritativeFarcasterBot = farcasterBotConfig.authoritativeFarcasterBot; if (!eventChannelID && !isAuthoritativeFarcasterBot) { return; } let channelCommunityID = noChannelCommunityID; if (eventChannelID) { const blobDownload = await getFarcasterChannelTagBlob(eventChannelID); if (blobDownload.found) { const blobText = await blobDownload.blob.text(); const blobObject = JSON.parse(blobText); const farcasterChannelTagBlob = assertWithValidator( blobObject, farcasterChannelTagBlobValidator, ); const { commCommunityID } = farcasterChannelTagBlob; const commCommunityKeyserverID = extractKeyserverIDFromID(commCommunityID); const keyserverID = await thisKeyserverID(); if (keyserverID !== commCommunityKeyserverID) { return; } channelCommunityID = commCommunityID.split('|')[1]; } else if (!blobDownload.found && blobDownload.status === 404) { if (!isAuthoritativeFarcasterBot) { return; } const taggerUserID = await taggerUserIDPromise; const newThreadResponse = await createTaggedFarcasterCommunity( eventChannelID, taggerUserID, ); channelCommunityID = newThreadResponse.newThreadID; } else { throw new ServerError('blob_fetch_failed'); } } const { hash: castHash, parent_hash: parentHash } = event.data; const taggerUserID = await taggerUserIDPromise; // we use the parent cast to create the sidebar source message if it exists const sidebarCastHash = parentHash ?? castHash; const sidebarThreadResponse = await createCastSidebar( sidebarCastHash, eventChannel?.id, channelCommunityID, taggerUserID, ); if (!sidebarThreadResponse) { return; } const inviteLinkName = Math.random().toString(36).slice(-9); const [inviteLink, neynarConfig] = await Promise.all([ createOrUpdatePublicLink(commbotViewer, { name: inviteLinkName, communityID: channelCommunityID, threadID: sidebarThreadResponse.newThreadID, }), neynarConfigPromise, ]); const introText = 'I created a thread on Comm. Join the conversation here:'; const replyText = `${introText} ${inviteLinkURL(inviteLink.name)}`; if (!neynarConfig?.signerUUID) { throw new ServerError('missing_signer_uuid'); } - const postCastResponse = await neynarClient?.postCast( - neynarConfig.signerUUID, - castHash, - replyText, - ); + const postCastResponse = await neynarClient?.postCast({ + signerUUID: neynarConfig.signerUUID, + parent: castHash, + text: replyText, + }); if (!postCastResponse?.success) { throw new ServerError('post_cast_failed'); } } export { taggedCommFarcasterResponder, taggedCommFarcasterInputValidator }; diff --git a/lib/utils/neynar-client.js b/lib/utils/neynar-client.js index 091646d0e..87feb7d0d 100644 --- a/lib/utils/neynar-client.js +++ b/lib/utils/neynar-client.js @@ -1,381 +1,393 @@ // @flow import invariant from 'invariant'; import { getMessageForException } from './errors.js'; import type { NeynarChannel, NeynarUser, NeynarCast, NeynarPostCastResponse, } from '../types/farcaster-types.js'; type FetchRelevantFollowersResponse = { +all_relevant_followers_dehydrated: $ReadOnlyArray<{ +user: { +fid: number, ... }, ... }>, ... }; type FetchFarcasterChannelsPagedResponse = { +channels: $ReadOnlyArray, +next: { +cursor: ?string, }, }; type FetchFarcasterChannelsResponse = { +channels: $ReadOnlyArray, ... }; 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 fetchFollowedFarcasterChannels(fid: string): 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: FetchFarcasterChannelsPagedResponse = await response.json(); const { channels, next } = json; channels.forEach(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; } async fetchFarcasterChannelByID(channelID: string): Promise { const params: { [string]: string } = { id: channelID, }; 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 fetchFarcasterChannelsByIDs( channelIDs: $ReadOnlyArray, ): Promise { const params: { [string]: string } = { ids: channelIDs.join(','), }; const url = getNeynarURL('2', 'channel/bulk', params); try { const response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json', api_key: this.apiKey, }, }); const json: FetchFarcasterChannelsResponse = await response.json(); return json.channels ? [...json.channels] : []; } catch (error) { console.log( 'Failed to fetch Farcaster channel infos:', 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: FetchFarcasterChannelsPagedResponse = 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 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; } } - async postCast( - signerUUID: string, - parent: string, - text: string, - ): Promise { + async postCast(params: { + +signerUUID: string, + +parent: string, + +text?: ?string, + +embeds?: ?$ReadOnlyArray<{ +url: string }>, + }): Promise { const url = getNeynarURL('2', 'cast', {}); - const body = { - signer_uuid: signerUUID, - parent, - text, + const body: { + signer_uuid: string, + parent: string, + text?: string, + embeds?: $ReadOnlyArray<{ +url: string }>, + } = { + signer_uuid: params.signerUUID, + parent: params.parent, }; + + if (params.embeds) { + body.embeds = params.embeds; + } + if (params.text) { + body.text = params.text; + } try { const response = await fetch(url, { method: 'POST', headers: { 'Accept': 'application/json', 'api_key': this.apiKey, 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); return await response.json(); } catch (error) { console.log( 'Failed to post Farcaster cast:', getMessageForException(error) ?? 'unknown', ); throw error; } } } export { NeynarClient };