diff --git a/keyserver/src/responders/farcaster-webhook-responders.js b/keyserver/src/responders/farcaster-webhook-responders.js index b08987573..2bd9886cf 100644 --- a/keyserver/src/responders/farcaster-webhook-responders.js +++ b/keyserver/src/responders/farcaster-webhook-responders.js @@ -1,204 +1,294 @@ // @flow import { createHmac } from 'crypto'; import type { $Request } from 'express'; +import invariant from 'invariant'; import bots from 'lib/facts/bots.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 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 { updateRole } from '../updaters/thread-updaters.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 } 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, + channelName: ?string, + channelCommunityID: string, + taggerUserID: ?string, +): Promise { + const sidebarCast = + await neynarClient?.fetchFarcasterCastByHash(sidebarCastHash); + + if (!sidebarCast) { + return null; + } + + const castAuthor = sidebarCast.author.username; + const channelText = channelName ? ` in channel ${channelName}` : ''; + const messageText = `${castAuthor} said "${sidebarCast.text}"${channelText}`; + + 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 neynarClient?.fetchFarcasterChannelByID(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 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'); } } - console.log(channelCommunityID); + + 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?.name, + channelCommunityID, + taggerUserID, + ); + + if (!sidebarThreadResponse) { + return; + } + + console.log(sidebarThreadResponse); } export { taggedCommFarcasterResponder, taggedCommFarcasterInputValidator }; diff --git a/lib/shared/sidebar-utils.js b/lib/shared/sidebar-utils.js index a0840895d..30b9fac50 100644 --- a/lib/shared/sidebar-utils.js +++ b/lib/shared/sidebar-utils.js @@ -1,249 +1,254 @@ // @flow import invariant from 'invariant'; import type { ParserRules } from './markdown.js'; import { getMessageTitle, isInvalidSidebarSource } from './message-utils.js'; import { relationshipBlockedInEitherDirection } from './relationship-utils.js'; import { createPendingThread, getSingleOtherUser, extractMentionedMembers, useThreadHasPermission, userIsMember, } from './thread-utils.js'; import type { ChatMessageInfoItem } from '../selectors/chat-selectors.js'; import { chatMessageItemEngagementTargetMessageInfo } from '../shared/chat-message-item-utils.js'; import { messageTypes } from '../types/message-types-enum.js'; import type { RobotextMessageInfo, ComposableMessageInfo, } from '../types/message-types.js'; import type { ThreadInfo } from '../types/minimally-encoded-thread-permissions-types.js'; import { threadPermissions } from '../types/thread-permission-types.js'; import { threadTypes, threadTypeIsThick, threadTypeIsPersonal, } from '../types/thread-types-enum.js'; import type { LoggedInUserInfo } from '../types/user-types.js'; import type { GetENSNames } from '../utils/ens-helpers.js'; import { entityTextToRawString, getEntityTextAsString, } from '../utils/entity-text.js'; import type { GetFCNames } from '../utils/farcaster-helpers.js'; import { useSelector } from '../utils/redux-utils.js'; import { trimText } from '../utils/text-utils.js'; type SharedCreatePendingSidebarInput = { +sourceMessageInfo: ComposableMessageInfo | RobotextMessageInfo, +parentThreadInfo: ThreadInfo, +loggedInUserInfo: LoggedInUserInfo, }; type BaseCreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +messageTitle: string, }; type UserIDAndUsername = { +id: string, +username: ?string, ... }; +function createSidebarThreadName(messageTitle: string): string { + return trimText(messageTitle, 30); +} + function baseCreatePendingSidebar( input: BaseCreatePendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, messageTitle, } = input; const { color, type: parentThreadType } = parentThreadInfo; - const threadName = trimText(messageTitle, 30); + const threadName = createSidebarThreadName(messageTitle); const initialMembers = new Map(); const { id: viewerID, username: viewerUsername } = loggedInUserInfo; initialMembers.set(viewerID, { id: viewerID, username: viewerUsername }); if (userIsMember(parentThreadInfo, sourceMessageInfo.creator.id)) { const { id: sourceAuthorID, username: sourceAuthorUsername } = sourceMessageInfo.creator; const initialMemberUserInfo = { id: sourceAuthorID, username: sourceAuthorUsername, }; initialMembers.set(sourceAuthorID, initialMemberUserInfo); } const singleOtherUser = getSingleOtherUser(parentThreadInfo, viewerID); if (threadTypeIsPersonal(parentThreadType) && singleOtherUser) { const singleOtherUsername = parentThreadInfo.members.find( member => member.id === singleOtherUser, )?.username; const singleOtherUserInfo = { id: singleOtherUser, username: singleOtherUsername, }; initialMembers.set(singleOtherUser, singleOtherUserInfo); } if (sourceMessageInfo.type === messageTypes.TEXT) { const mentionedMembersOfParent = extractMentionedMembers( sourceMessageInfo.text, parentThreadInfo, ); for (const [memberID, member] of mentionedMembersOfParent) { initialMembers.set(memberID, member); } } return createPendingThread({ viewerID, threadType: threadTypeIsThick(parentThreadInfo.type) ? threadTypes.THICK_SIDEBAR : threadTypes.SIDEBAR, members: [...initialMembers.values()], parentThreadInfo, threadColor: color, name: threadName, sourceMessageID: sourceMessageInfo.id, }); } // The message title here may have ETH addresses that aren't resolved to ENS // names. This function should only be used in cases where we're sure that we // don't care about the thread title. We should prefer createPendingSidebar // wherever possible type CreateUnresolvedPendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, }; function createUnresolvedPendingSidebar( input: CreateUnresolvedPendingSidebarInput, ): ThreadInfo { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = entityTextToRawString(messageTitleEntityText, { ignoreViewer: true, }); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } type CreatePendingSidebarInput = { ...SharedCreatePendingSidebarInput, +markdownRules: ParserRules, +getENSNames: ?GetENSNames, +getFCNames: ?GetFCNames, }; async function createPendingSidebar( input: CreatePendingSidebarInput, ): Promise { const { sourceMessageInfo, parentThreadInfo, loggedInUserInfo, markdownRules, getENSNames, getFCNames, } = input; const messageTitleEntityText = getMessageTitle( sourceMessageInfo, parentThreadInfo, parentThreadInfo, markdownRules, ); const messageTitle = await getEntityTextAsString( messageTitleEntityText, { getENSNames, getFCNames }, { ignoreViewer: true }, ); invariant( messageTitle !== null && messageTitle !== undefined, 'getEntityTextAsString only returns falsey when passed falsey', ); return baseCreatePendingSidebar({ sourceMessageInfo, parentThreadInfo, messageTitle, loggedInUserInfo, }); } function useCanCreateSidebarFromMessage( threadInfo: ThreadInfo, messageInfo: ?ComposableMessageInfo | RobotextMessageInfo, ): boolean { const creatorID = messageInfo?.creator.id; const messageCreatorUserInfo = useSelector(state => creatorID ? state.userStore.userInfos[creatorID] : null, ); const hasCreateSidebarsPermission = useThreadHasPermission( threadInfo, threadPermissions.CREATE_SIDEBARS, ); if (!hasCreateSidebarsPermission) { return false; } if ( !messageInfo || (!messageInfo.id && !threadTypeIsThick(threadInfo.type)) || (threadInfo.sourceMessageID && threadInfo.sourceMessageID === messageInfo.id) || isInvalidSidebarSource(messageInfo) ) { return false; } const messageCreatorRelationship = messageCreatorUserInfo?.relationshipStatus; return ( !messageCreatorRelationship || !relationshipBlockedInEitherDirection(messageCreatorRelationship) ); } function useSidebarExistsOrCanBeCreated( threadInfo: ThreadInfo, messageItem: ChatMessageInfoItem, ): boolean { const engagementTargetMessageInfo = chatMessageItemEngagementTargetMessageInfo(messageItem); const canCreateSidebarFromMessage = useCanCreateSidebarFromMessage( threadInfo, engagementTargetMessageInfo, ); return !!messageItem.threadCreatedFromMessage || canCreateSidebarFromMessage; } export { createUnresolvedPendingSidebar, createPendingSidebar, useCanCreateSidebarFromMessage, useSidebarExistsOrCanBeCreated, + createSidebarThreadName, };