diff --git a/keyserver/src/creators/farcaster-channel-tag-creator.js b/keyserver/src/creators/farcaster-channel-tag-creator.js index 07ff1f519..3d0b35e74 100644 --- a/keyserver/src/creators/farcaster-channel-tag-creator.js +++ b/keyserver/src/creators/farcaster-channel-tag-creator.js @@ -1,174 +1,237 @@ // @flow import uuid from 'uuid'; import { farcasterChannelTagBlobHash } from 'lib/shared/community-utils.js'; +import { + hasMinCodeVersion, + NEXT_CODE_VERSION, +} from 'lib/shared/version-utils.js'; import type { CreateOrUpdateFarcasterChannelTagRequest, CreateOrUpdateFarcasterChannelTagResponse, } from 'lib/types/community-types.js'; import { threadPermissions } from 'lib/types/thread-permission-types.js'; import type { BlobOperationResult } from 'lib/utils/blob-service.js'; import { ServerError } from 'lib/utils/errors.js'; import { dbQuery, SQL, MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE, } from '../database/database.js'; import { fetchCommunityInfos } from '../fetchers/community-fetchers.js'; +import { fetchServerThreadInfos } from '../fetchers/thread-fetchers.js'; import { checkThreadPermission } from '../fetchers/thread-permission-fetchers.js'; import { uploadBlobKeyserverWrapper, assignHolder, download, deleteBlob, type BlobDownloadResult, } from '../services/blob.js'; import { Viewer } from '../session/viewer.js'; +import { updateThread } from '../updaters/thread-updaters.js'; import { thisKeyserverID } from '../user/identity.js'; +import { neynarClient } from '../utils/fc-cache.js'; import { getAndAssertKeyserverURLFacts } from '../utils/urls.js'; async function createOrUpdateFarcasterChannelTag( viewer: Viewer, request: CreateOrUpdateFarcasterChannelTagRequest, ): Promise { const permissionPromise = checkThreadPermission( viewer, request.commCommunityID, threadPermissions.MANAGE_FARCASTER_CHANNEL_TAGS, ); const [hasPermission, communityInfos, blobDownload, keyserverID] = await Promise.all([ permissionPromise, fetchCommunityInfos(viewer, [request.commCommunityID]), getFarcasterChannelTagBlob(request.farcasterChannelID), thisKeyserverID(), ]); if (!hasPermission) { throw new ServerError('invalid_credentials'); } if (communityInfos.length !== 1) { throw new ServerError('invalid_parameters'); } if (blobDownload.found) { throw new ServerError('already_in_use'); } const communityID = `${keyserverID}|${request.commCommunityID}`; const blobHolder = uuid.v4(); const blobResult = await uploadFarcasterChannelTagBlob( communityID, request.farcasterChannelID, blobHolder, ); if (!blobResult.success) { if (blobResult.reason === 'HASH_IN_USE') { throw new ServerError('already_in_use'); } else { throw new ServerError('unknown_error'); } } const query = SQL` START TRANSACTION; SELECT farcaster_channel_id, blob_holder INTO @currentFarcasterChannelID, @currentBlobHolder FROM communities WHERE id = ${request.commCommunityID} FOR UPDATE; UPDATE communities SET farcaster_channel_id = ${request.farcasterChannelID}, blob_holder = ${blobHolder} WHERE id = ${request.commCommunityID}; COMMIT; SELECT @currentFarcasterChannelID AS oldFarcasterChannelID, @currentBlobHolder AS oldBlobHolder; `; try { const [transactionResult] = await dbQuery(query, { multipleStatements: true, }); const selectResult = transactionResult.pop(); const [{ oldFarcasterChannelID, oldBlobHolder }] = selectResult; if (oldFarcasterChannelID && oldBlobHolder) { await deleteBlob( { hash: farcasterChannelTagBlobHash(oldFarcasterChannelID), holder: oldBlobHolder, }, true, ); } } catch (error) { await deleteBlob( { hash: farcasterChannelTagBlobHash(request.farcasterChannelID), holder: blobHolder, }, true, ); if (error.errno === MYSQL_DUPLICATE_ENTRY_FOR_KEY_ERROR_CODE) { throw new ServerError('already_in_use'); } throw new ServerError('invalid_parameters'); } + const neynarChannelDescriptionPromise = (async () => { + if (!neynarClient) { + return ''; + } + const channelInfo = await neynarClient?.fetchFarcasterChannelByID( + request.farcasterChannelID, + ); + return channelInfo?.description ?? ''; + })(); + + const [fcChannelDescription, serverThreadInfos] = await Promise.all([ + neynarChannelDescriptionPromise, + fetchServerThreadInfos({ threadID: request.commCommunityID }), + ]); + + const threadInfo = serverThreadInfos.threadInfos[request.commCommunityID]; + if (!threadInfo) { + return { + commCommunityID: request.commCommunityID, + farcasterChannelID: request.farcasterChannelID, + }; + } + const { avatar, description } = threadInfo; + if (avatar && description) { + return { + commCommunityID: request.commCommunityID, + farcasterChannelID: request.farcasterChannelID, + }; + } + + let changes = {}; + if (!avatar) { + changes = { ...changes, avatar: { type: 'farcaster' } }; + } + if (!description) { + changes = { ...changes, description: fcChannelDescription }; + } + + const changeThreadSettingsResult = await updateThread(viewer, { + threadID: request.commCommunityID, + changes, + }); + + if ( + !hasMinCodeVersion(viewer.platformDetails, { + native: NEXT_CODE_VERSION, + web: NEXT_CODE_VERSION, + }) + ) { + return { + commCommunityID: request.commCommunityID, + farcasterChannelID: request.farcasterChannelID, + }; + } + return { commCommunityID: request.commCommunityID, farcasterChannelID: request.farcasterChannelID, + ...changeThreadSettingsResult, }; } function getFarcasterChannelTagBlob( secret: string, ): Promise { const hash = farcasterChannelTagBlobHash(secret); return download(hash); } async function uploadFarcasterChannelTagBlob( commCommunityID: string, farcasterChannelID: string, holder: string, ): Promise { const { baseDomain, basePath } = getAndAssertKeyserverURLFacts(); const keyserverURL = baseDomain + basePath; const payload = { commCommunityID, farcasterChannelID, keyserverURL, }; const payloadString = JSON.stringify(payload); const hash = farcasterChannelTagBlobHash(farcasterChannelID); const blob = new Blob([payloadString]); const uploadResult = await uploadBlobKeyserverWrapper(blob, hash); if (!uploadResult.success) { return uploadResult; } return await assignHolder({ holder, hash }); } export { createOrUpdateFarcasterChannelTag, uploadFarcasterChannelTagBlob }; diff --git a/lib/actions/community-actions.js b/lib/actions/community-actions.js index 1f8d06df6..7ab32d555 100644 --- a/lib/actions/community-actions.js +++ b/lib/actions/community-actions.js @@ -1,165 +1,160 @@ // @flow import type { CallSingleKeyserverEndpoint } from '../keyserver-conn/call-single-keyserver-endpoint.js'; import { extractKeyserverIDFromID } from '../keyserver-conn/keyserver-call-utils.js'; import { useKeyserverCall } from '../keyserver-conn/keyserver-call.js'; import type { CallKeyserverEndpoint } from '../keyserver-conn/keyserver-conn-types.js'; import type { ServerCommunityInfo, FetchCommunityInfosResponse, FetchAllCommunityInfosWithNamesResponse, CreateOrUpdateFarcasterChannelTagRequest, CreateOrUpdateFarcasterChannelTagResponse, DeleteFarcasterChannelTagRequest, DeleteFarcasterChannelTagPayload, } from '../types/community-types.js'; const updateCalendarCommunityFilter = 'UPDATE_CALENDAR_COMMUNITY_FILTER'; const clearCalendarCommunityFilter = 'CLEAR_CALENDAR_COMMUNITY_FILTER'; const updateChatCommunityFilter = 'UPDATE_CHAT_COMMUNITY_FILTER'; const clearChatCommunityFilter = 'CLEAR_CHAT_COMMUNITY_FILTER'; const addCommunityActionType = 'ADD_COMMUNITY'; const fetchCommunityInfosActionTypes = Object.freeze({ started: 'FETCH_COMMUNITY_INFOS_STARTED', success: 'FETCH_COMMUNITY_INFOS_SUCCESS', failed: 'FETCH_COMMUNITY_INFOS_FAILED', }); const fetchCommunityInfos = ( callKeyserverEndpoint: CallKeyserverEndpoint, allKeyserverIDs: $ReadOnlyArray, ): (() => Promise) => async () => { const requests: { [string]: void } = {}; for (const keyserverID of allKeyserverIDs) { requests[keyserverID] = undefined; } const responses = await callKeyserverEndpoint( 'fetch_community_infos', requests, ); let communityInfos: $ReadOnlyArray = []; for (const keyserverID in responses) { communityInfos = communityInfos.concat( responses[keyserverID].communityInfos, ); } return { communityInfos, }; }; function useFetchCommunityInfos(): () => Promise { return useKeyserverCall(fetchCommunityInfos); } const fetchAllCommunityInfosWithNamesActionTypes = Object.freeze({ started: 'FETCH_ALL_COMMUNITY_INFOS_WITH_NAMES_STARTED', success: 'FETCH_ALL_COMMUNITY_INFOS_WITH_NAMES_SUCCESS', failed: 'FETCH_ALL_COMMUNITY_INFOS_WITH_NAMES_FAILED', }); const fetchAllCommunityInfosWithNames = ( callSingleKeyserverEndpoint: CallSingleKeyserverEndpoint, ): (() => Promise) => () => callSingleKeyserverEndpoint('fetch_all_community_infos_with_names'); const createOrUpdateFarcasterChannelTagActionTypes = Object.freeze({ started: 'CREATE_OR_UPDATE_FARCASTER_CHANNEL_TAG_STARTED', success: 'CREATE_OR_UPDATE_FARCASTER_CHANNEL_TAG_SUCCESS', failed: 'CREATE_OR_UPDATE_FARCASTER_CHANNEL_TAG_FAILED', }); const createOrUpdateFarcasterChannelTag = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: CreateOrUpdateFarcasterChannelTagRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.commCommunityID); const requests = { [keyserverID]: { commCommunityID: input.commCommunityID, farcasterChannelID: input.farcasterChannelID, }, }; const responses = await callKeyserverEndpoint( 'create_or_update_farcaster_channel_tag', requests, ); - const response = responses[keyserverID]; - - return { - commCommunityID: response.commCommunityID, - farcasterChannelID: response.farcasterChannelID, - }; + return responses[keyserverID]; }; function useCreateOrUpdateFarcasterChannelTag(): ( input: CreateOrUpdateFarcasterChannelTagRequest, ) => Promise { return useKeyserverCall(createOrUpdateFarcasterChannelTag); } const deleteFarcasterChannelTagActionTypes = Object.freeze({ started: 'DELETE_FARCASTER_CHANNEL_TAG_STARTED', success: 'DELETE_FARCASTER_CHANNEL_TAG_SUCCESS', failed: 'DELETE_FARCASTER_CHANNEL_TAG_FAILED', }); const deleteFarcasterChannelTag = ( callKeyserverEndpoint: CallKeyserverEndpoint, ): (( input: DeleteFarcasterChannelTagRequest, ) => Promise) => async input => { const keyserverID = extractKeyserverIDFromID(input.commCommunityID); const requests = { [keyserverID]: { commCommunityID: input.commCommunityID, farcasterChannelID: input.farcasterChannelID, }, }; await callKeyserverEndpoint('delete_farcaster_channel_tag', requests); return { commCommunityID: input.commCommunityID, }; }; function useDeleteFarcasterChannelTag(): ( input: DeleteFarcasterChannelTagRequest, ) => Promise { return useKeyserverCall(deleteFarcasterChannelTag); } export { updateCalendarCommunityFilter, clearCalendarCommunityFilter, updateChatCommunityFilter, clearChatCommunityFilter, addCommunityActionType, fetchCommunityInfosActionTypes, useFetchCommunityInfos, fetchAllCommunityInfosWithNamesActionTypes, fetchAllCommunityInfosWithNames, createOrUpdateFarcasterChannelTagActionTypes, useCreateOrUpdateFarcasterChannelTag, deleteFarcasterChannelTagActionTypes, useDeleteFarcasterChannelTag, }; diff --git a/lib/types/community-types.js b/lib/types/community-types.js index 485cfbd96..9ba85fca7 100644 --- a/lib/types/community-types.js +++ b/lib/types/community-types.js @@ -1,85 +1,91 @@ // @flow import t, { type TInterface } from 'tcomb'; +import type { RawMessageInfo } from './message-types.js'; +import type { ServerUpdateInfo } from './update-types.js'; import { tID, tShape } from '../utils/validation-utils.js'; export type CommunityInfo = { +farcasterChannelID: ?string, }; export type CommunityInfos = { +[threadID: string]: CommunityInfo }; export type CommunityStore = { +communityInfos: CommunityInfos, }; export type AddCommunityPayload = { +id: string, +newCommunityInfo: CommunityInfo, }; export type ServerCommunityInfo = { +id: string, +farcasterChannelID: ?string, }; export const serverCommunityInfoValidator: TInterface = tShape({ id: tID, farcasterChannelID: t.maybe(t.String), }); export type ServerCommunityInfoWithCommunityName = $ReadOnly<{ ...ServerCommunityInfo, +communityName: string, }>; export const serverCommunityInfoWithCommunityNameValidator: TInterface = tShape({ id: tID, farcasterChannelID: t.maybe(t.String), communityName: t.String, }); export type FetchCommunityInfosResponse = { +communityInfos: $ReadOnlyArray, }; export type FetchAllCommunityInfosWithNamesResponse = { +allCommunityInfosWithNames: $ReadOnlyArray, }; export type CreateOrUpdateFarcasterChannelTagRequest = { +commCommunityID: string, +farcasterChannelID: string, }; export type CreateOrUpdateFarcasterChannelTagResponse = { +commCommunityID: string, +farcasterChannelID: string, + +updatesResult?: ?{ + +newUpdates: $ReadOnlyArray, + }, + +newMessageInfos?: ?$ReadOnlyArray, }; export type DeleteFarcasterChannelTagRequest = { +commCommunityID: string, +farcasterChannelID: string, }; export type DeleteFarcasterChannelTagPayload = { +commCommunityID: string, }; export type OngoingJoinCommunityData = { +resolve: () => mixed, +reject: () => mixed, +communityID: string, +threadID: ?string, }; export type JoinCommunityStep = | 'inactive' | 'add_keyserver' | 'auth_to_keyserver' | 'join_community' | 'join_thread' | 'finished'; diff --git a/lib/types/validators/farcaster-channel-tag-validators.js b/lib/types/validators/farcaster-channel-tag-validators.js index c87e50f2e..4fe34fcea 100644 --- a/lib/types/validators/farcaster-channel-tag-validators.js +++ b/lib/types/validators/farcaster-channel-tag-validators.js @@ -1,12 +1,27 @@ // @flow import t, { type TInterface } from 'tcomb'; import { tShape, tID } from '../../utils/validation-utils.js'; import type { CreateOrUpdateFarcasterChannelTagResponse } from '../community-types'; +import { rawMessageInfoValidator } from '../message-types.js'; +import { + serverUpdateInfoValidator, + type ServerUpdateInfo, +} from '../update-types.js'; + +const updatesResultValidator: TInterface<{ + +newUpdates: $ReadOnlyArray, +}> = tShape<{ + +newUpdates: $ReadOnlyArray, +}>({ + newUpdates: t.list(serverUpdateInfoValidator), +}); export const createOrUpdateFarcasterChannelTagResponseValidator: TInterface = tShape({ commCommunityID: tID, farcasterChannelID: t.String, + newMessageInfos: t.maybe(t.list(rawMessageInfoValidator)), + updatesResult: t.maybe(updatesResultValidator), });