diff --git a/src/lib/telegram/chat/__tests__/connectTelegramToAccount.test.ts b/src/lib/telegram/chat/__tests__/connectTelegramToAccount.test.ts index 2c3e3e15..9d3147fd 100644 --- a/src/lib/telegram/chat/__tests__/connectTelegramToAccount.test.ts +++ b/src/lib/telegram/chat/__tests__/connectTelegramToAccount.test.ts @@ -43,20 +43,25 @@ describe('connectTelegramToAccount', () => { ); }); - it('replies with a not-found message when no wallet matches the email', async () => { + it('sends a verification code with a null artistId when no wallet matches the email', async () => { vi.mocked(getPrivyWalletAddressesByEmail).mockResolvedValue([]); const thread = makeThread(); await connectTelegramToAccount(thread as never, 'user@example.com'); expect(selectWallets).not.toHaveBeenCalled(); - expect(sendCodeHandler).not.toHaveBeenCalled(); + expect(sendCodeHandler).toHaveBeenCalledWith('user@example.com'); + expect(setPendingCode).toHaveBeenCalledWith(thread, { + email: 'user@example.com', + artistId: null, + username: null, + }); expect(thread.post).toHaveBeenCalledWith( - expect.stringContaining("couldn't find") + expect.stringContaining('verification code') ); }); - it('replies with a not-found message when the wallet has no linked artist', async () => { + it('sends a verification code with a null artistId when the wallet has no linked artist', async () => { vi.mocked(getPrivyWalletAddressesByEmail).mockResolvedValue([ WALLET_ADDRESS, ]); @@ -67,13 +72,18 @@ describe('connectTelegramToAccount', () => { await connectTelegramToAccount(thread as never, 'user@example.com'); - expect(sendCodeHandler).not.toHaveBeenCalled(); + expect(sendCodeHandler).toHaveBeenCalledWith('user@example.com'); + expect(setPendingCode).toHaveBeenCalledWith(thread, { + email: 'user@example.com', + artistId: null, + username: null, + }); expect(thread.post).toHaveBeenCalledWith( - expect.stringContaining("couldn't find") + expect.stringContaining('verification code') ); }); - it('sends a verification code and does not link the artist yet when a match is found', async () => { + it('sends a verification code with the matched artistId when a match is found', async () => { vi.mocked(getPrivyWalletAddressesByEmail).mockResolvedValue([ WALLET_ADDRESS, ]); @@ -95,4 +105,26 @@ describe('connectTelegramToAccount', () => { expect.stringContaining('verification code') ); }); + + it('replies with the same verification-code message regardless of whether a match was found', async () => { + vi.mocked(getPrivyWalletAddressesByEmail).mockResolvedValue([]); + const unmatchedThread = makeThread(); + await connectTelegramToAccount( + unmatchedThread as never, + 'user@example.com' + ); + + vi.mocked(getPrivyWalletAddressesByEmail).mockResolvedValue([ + WALLET_ADDRESS, + ]); + vi.mocked(selectWallets).mockResolvedValue({ + data: [{ artist: ARTIST }], + } as never); + const matchedThread = makeThread(); + await connectTelegramToAccount(matchedThread as never, 'user@example.com'); + + expect(unmatchedThread.post.mock.calls[0]).toEqual( + matchedThread.post.mock.calls[0] + ); + }); }); diff --git a/src/lib/telegram/chat/__tests__/verifyTelegramCode.test.ts b/src/lib/telegram/chat/__tests__/verifyTelegramCode.test.ts index 819e1c82..6d64103e 100644 --- a/src/lib/telegram/chat/__tests__/verifyTelegramCode.test.ts +++ b/src/lib/telegram/chat/__tests__/verifyTelegramCode.test.ts @@ -3,12 +3,16 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('@/lib/privy/authenticatePrivyPasswordless', () => ({ default: vi.fn(), })); +vi.mock('@/lib/privy/ensurePrivySocialWallet', () => ({ default: vi.fn() })); +vi.mock('@/lib/artists/getOrCreateArtist', () => ({ default: vi.fn() })); vi.mock('@/lib/supabase/in_process_artists/upsertArtists', () => ({ upsertArtists: vi.fn(), })); vi.mock('../clearPendingCode', () => ({ default: vi.fn() })); import authenticatePrivyPasswordless from '@/lib/privy/authenticatePrivyPasswordless'; +import ensurePrivySocialWallet from '@/lib/privy/ensurePrivySocialWallet'; +import getOrCreateArtist from '@/lib/artists/getOrCreateArtist'; import { upsertArtists } from '@/lib/supabase/in_process_artists/upsertArtists'; import clearPendingCode from '../clearPendingCode'; import verifyTelegramCode from '../verifyTelegramCode'; @@ -19,6 +23,8 @@ const PENDING = { artistId: 'uuid-artist-1234', username: 'alice', }; +const NEW_WALLET_ADDRESS = '0xNewWallet'; +const NEW_ARTIST_ID = 'uuid-new-artist'; const makeThread = () => ({ post: vi.fn().mockResolvedValue(undefined), @@ -28,6 +34,12 @@ beforeEach(() => { vi.clearAllMocks(); vi.mocked(upsertArtists).mockResolvedValue([{ id: PENDING.artistId }]); vi.mocked(clearPendingCode).mockResolvedValue(undefined); + vi.mocked(ensurePrivySocialWallet).mockResolvedValue(NEW_WALLET_ADDRESS); + vi.mocked(getOrCreateArtist).mockResolvedValue({ + artistId: NEW_ARTIST_ID, + primaryWallet: NEW_WALLET_ADDRESS, + wallets: [], + } as never); }); describe('verifyTelegramCode', () => { @@ -73,6 +85,38 @@ describe('verifyTelegramCode', () => { telegram: TG_USERNAME, }); expect(clearPendingCode).toHaveBeenCalledWith(thread); + expect(ensurePrivySocialWallet).not.toHaveBeenCalled(); + expect(getOrCreateArtist).not.toHaveBeenCalled(); expect(thread.post).toHaveBeenCalledWith(expect.stringContaining('alice')); }); + + it('creates a wallet and artist, then links telegram, when no artist was matched', async () => { + const authResult = { user: { id: 'privy-user-1' } }; + vi.mocked(authenticatePrivyPasswordless).mockResolvedValue( + authResult as never + ); + const thread = makeThread(); + const unmatchedPending = { ...PENDING, artistId: null, username: null }; + + await verifyTelegramCode( + thread as never, + '123456', + TG_USERNAME, + unmatchedPending + ); + + expect(ensurePrivySocialWallet).toHaveBeenCalledWith(authResult); + expect(getOrCreateArtist).toHaveBeenCalledWith({ + address: NEW_WALLET_ADDRESS, + type: 'privy', + }); + expect(upsertArtists).toHaveBeenCalledWith({ + id: NEW_ARTIST_ID, + telegram: TG_USERNAME, + }); + expect(clearPendingCode).toHaveBeenCalledWith(thread); + expect(thread.post).toHaveBeenCalledWith( + expect.stringContaining('Welcome to In Process') + ); + }); }); diff --git a/src/lib/telegram/chat/connectTelegramToAccount.ts b/src/lib/telegram/chat/connectTelegramToAccount.ts index 570512a2..c06a9392 100644 --- a/src/lib/telegram/chat/connectTelegramToAccount.ts +++ b/src/lib/telegram/chat/connectTelegramToAccount.ts @@ -9,8 +9,6 @@ import setPendingCode from './setPendingCode'; const INVALID_EMAIL_MESSAGE = "That doesn't look like a valid email address. Please try again."; -const NO_ACCOUNT_FOUND_MESSAGE = - "We couldn't find an In Process account for that email yet. Please sign up at https://inprocess.world, then reply here with your email again to connect your Telegram."; const CODE_SENT_MESSAGE = "We've sent a 6-digit verification code to that email. Please reply with the code to confirm it's yours."; @@ -32,21 +30,17 @@ async function connectTelegramToAccount( )?.artist ?? null) : null; - if (!artist) { - // TODO: no In Process account exists for this email/wallet yet — support - // creating one directly from this Telegram flow instead of requiring web signup first. - await thread.post(NO_ACCOUNT_FOUND_MESSAGE); - return; - } - // Require proof the sender owns this email before linking their Telegram // account to it — otherwise anyone could hijack an artist's account by - // typing in an email address they don't own. + // typing in an email address they don't own. Send the code and reply + // identically whether or not an artist was found: revealing that up front + // would let anyone probe arbitrary emails to learn which ones have an + // In Process account. await sendCodeHandler(email); await setPendingCode(thread, { email, - artistId: artist.id, - username: artist.username, + artistId: artist?.id ?? null, + username: artist?.username ?? null, }); await clearPendingEmail(thread); await thread.post(CODE_SENT_MESSAGE); diff --git a/src/lib/telegram/chat/getPendingCode.ts b/src/lib/telegram/chat/getPendingCode.ts index a8ef1904..6bb1407b 100644 --- a/src/lib/telegram/chat/getPendingCode.ts +++ b/src/lib/telegram/chat/getPendingCode.ts @@ -4,7 +4,7 @@ import getStateAdapter, { TELEGRAM_PENDING_CODE_KEY } from './stateAdapter'; export interface PendingCode { email: string; - artistId: string; + artistId: string | null; username: string | null; } diff --git a/src/lib/telegram/chat/verifyTelegramCode.ts b/src/lib/telegram/chat/verifyTelegramCode.ts index 89a117c6..ce2dae9e 100644 --- a/src/lib/telegram/chat/verifyTelegramCode.ts +++ b/src/lib/telegram/chat/verifyTelegramCode.ts @@ -1,13 +1,18 @@ +import type { Address } from 'viem'; import type { Thread } from 'chat'; import type { TelegramThreadState } from './telegramThreadState'; import telegramCodeSchema from '@/lib/schema/telegramCodeSchema'; import authenticatePrivyPasswordless from '@/lib/privy/authenticatePrivyPasswordless'; +import ensurePrivySocialWallet from '@/lib/privy/ensurePrivySocialWallet'; +import getOrCreateArtist from '@/lib/artists/getOrCreateArtist'; import { upsertArtists } from '@/lib/supabase/in_process_artists/upsertArtists'; import clearPendingCode from './clearPendingCode'; import type { PendingCode } from './getPendingCode'; const INVALID_CODE_MESSAGE = "That code doesn't look right. Please check your email and try again."; +const WELCOME_MESSAGE = + "Welcome to In Process! We've created your account and connected your Telegram. You can now send photos and videos to publish moments."; async function verifyTelegramCode( thread: Thread, @@ -21,15 +26,31 @@ async function verifyTelegramCode( return; } + let authResult; try { - await authenticatePrivyPasswordless(pending.email, result.data); + authResult = await authenticatePrivyPasswordless( + pending.email, + result.data + ); } catch { await thread.post(INVALID_CODE_MESSAGE); return; } - await upsertArtists({ id: pending.artistId, telegram: telegramUsername }); await clearPendingCode(thread); + + if (!pending.artistId) { + const walletAddress = await ensurePrivySocialWallet(authResult); + const artist = await getOrCreateArtist({ + address: walletAddress as Address, + type: 'privy', + }); + await upsertArtists({ id: artist.artistId, telegram: telegramUsername }); + await thread.post(WELCOME_MESSAGE); + return; + } + + await upsertArtists({ id: pending.artistId, telegram: telegramUsername }); await thread.post( `You're all set! Your Telegram is now connected to your In Process account${pending.username ? ` (${pending.username})` : ''}. You can now send photos and videos to publish moments.` );