diff --git a/src/features/posts/bird-client.server.ts b/src/features/posts/bird-client.server.ts new file mode 100644 index 0000000..92dc594 --- /dev/null +++ b/src/features/posts/bird-client.server.ts @@ -0,0 +1,13 @@ +import { TwitterClient } from '@yuta/bird' +import type { BirdReader } from './post-service' + +let client: TwitterClient | undefined + +export function getBirdReader(): BirdReader { + client ??= new TwitterClient({ + relayBaseUrl: process.env.TWITTER_RELAY_BASE_URL, + profileName: process.env.BIRD_PROFILE_NAME, + timeoutMs: 20_000, + }) + return client +} diff --git a/src/features/posts/post-service.test.ts b/src/features/posts/post-service.test.ts new file mode 100644 index 0000000..2ac84a8 --- /dev/null +++ b/src/features/posts/post-service.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it, vi } from 'vitest' +import { type BirdReader, loadUserPage, searchPage } from './post-service' + +const reader = (): BirdReader => ({ + getUserIdByUsername: vi + .fn() + .mockResolvedValue({ success: true, userId: '42' }), + getUserTweetsPaged: vi.fn().mockResolvedValue({ + success: true, + tweets: [ + { id: '1', text: 'hello', author: { username: 'yuta', name: 'Yuta' } }, + ], + nextCursor: 'user-next', + }), + getAllSearchResults: vi.fn().mockResolvedValue({ + success: true, + tweets: [], + nextCursor: 'search-next', + }), +}) + +describe('loadUserPage', () => { + it('resolves a handle and fetches exactly one page', async () => { + const client = reader() + const result = await loadUserPage(client, { + target: 'https://x.com/yuta', + cursor: 'cursor-1', + }) + + expect(client.getUserIdByUsername).toHaveBeenCalledWith('yuta') + expect(client.getUserTweetsPaged).toHaveBeenCalledWith('42', 20, { + cursor: 'cursor-1', + maxPages: 1, + pageDelayMs: 0, + }) + expect(result).toMatchObject({ + ok: true, + page: { nextCursor: 'user-next' }, + }) + }) + + it('returns a safe not-found error', async () => { + const client = reader() + vi.mocked(client.getUserIdByUsername).mockResolvedValue({ + success: false, + error: 'User not found: private relay detail', + }) + + expect(await loadUserPage(client, { target: 'missing' })).toEqual({ + ok: false, + error: { + code: 'user-not-found', + message: 'ユーザーが見つかりませんでした。', + retryable: false, + }, + }) + }) +}) + +describe('searchPage', () => { + it('forwards Top and appends follows once', async () => { + const client = reader() + await searchPage(client, { + query: 'AI lang:ja', + product: 'Top', + following: true, + cursor: 'cursor-2', + }) + + expect(client.getAllSearchResults).toHaveBeenCalledWith( + 'AI lang:ja filter:follows', + { product: 'Top', cursor: 'cursor-2', maxPages: 1 }, + ) + }) + + it('removes raw response data from posts and quoted posts', async () => { + const client = reader() + vi.mocked(client.getAllSearchResults).mockResolvedValue({ + success: true, + tweets: [ + { + id: '1', + text: 'hello', + author: { username: 'yuta', name: 'Yuta' }, + quotedTweet: { + id: '2', + text: 'quoted', + author: { username: 'bird', name: 'Bird' }, + _raw: { rest_id: 'private-quoted-detail' }, + }, + _raw: { rest_id: 'private-detail' }, + }, + ], + }) + + const result = await searchPage(client, { + query: 'AI', + product: 'Latest', + following: false, + }) + + expect(result).toEqual({ + ok: true, + page: { + nextCursor: undefined, + tweets: [ + { + id: '1', + text: 'hello', + author: { username: 'yuta', name: 'Yuta' }, + quotedTweet: { + id: '2', + text: 'quoted', + author: { username: 'bird', name: 'Bird' }, + }, + }, + ], + }, + }) + }) +}) diff --git a/src/features/posts/post-service.ts b/src/features/posts/post-service.ts new file mode 100644 index 0000000..51f170f --- /dev/null +++ b/src/features/posts/post-service.ts @@ -0,0 +1,135 @@ +import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird' +import { buildSearchQuery, InputError, normalizeUserTarget } from './inputs' +import type { + LoadError, + LoadResult, + Post, + SearchPageInput, + UserPageInput, +} from './types' + +type UserLookupResult = { + success: boolean + userId?: string + error?: string +} + +export interface BirdReader { + getUserIdByUsername(username: string): Promise + getUserTweetsPaged( + userId: string, + limit: number, + options: { + cursor?: string + maxPages: number + pageDelayMs: number + }, + ): Promise + getAllSearchResults( + query: string, + options: { + product: SearchProduct + cursor?: string + maxPages: number + }, + ): Promise +} + +const failure = ( + code: LoadError['code'], + message: string, + retryable: boolean, +): LoadResult => ({ ok: false, error: { code, message, retryable } }) + +function upstreamFailure(message = ''): LoadResult { + const lower = message.toLowerCase() + if (lower.includes('timeout') || lower.includes('aborted')) { + return failure('timeout', '取得がタイムアウトしました。', true) + } + if (lower.includes('not found')) { + return failure('user-not-found', 'ユーザーが見つかりませんでした。', false) + } + if (lower.includes('suspended') || lower.includes('protected')) { + return failure( + 'user-unavailable', + 'このユーザーの投稿は取得できません。', + false, + ) + } + return failure('upstream', 'X から投稿を取得できませんでした。', true) +} + +function publicPost(tweet: TweetData): Post { + const post = { ...tweet } + delete post._raw + if (post.quotedTweet) { + post.quotedTweet = publicPost(post.quotedTweet) + } + return post +} + +function resultPage(result: SearchResult): LoadResult { + return result.success + ? { + ok: true, + page: { + tweets: result.tweets.map(publicPost), + nextCursor: result.nextCursor, + }, + } + : upstreamFailure(result.error) +} + +export async function loadUserPage( + reader: BirdReader, + input: UserPageInput, +): Promise { + try { + const handle = normalizeUserTarget(input.target) + const user = await reader.getUserIdByUsername(handle) + if (!user.success || !user.userId) { + return upstreamFailure(user.error) + } + return resultPage( + await reader.getUserTweetsPaged(user.userId, 20, { + cursor: input.cursor, + maxPages: 1, + pageDelayMs: 0, + }), + ) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return failure('timeout', '取得がタイムアウトしました。', true) + } + if (error instanceof InputError) { + return failure('invalid-input', error.message, false) + } + return upstreamFailure(error instanceof Error ? error.message : '') + } +} + +export async function searchPage( + reader: BirdReader, + input: SearchPageInput, +): Promise { + try { + return resultPage( + await reader.getAllSearchResults( + buildSearchQuery(input.query, input.following), + { + product: input.product, + cursor: input.cursor, + maxPages: 1, + }, + ), + ) + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return failure('timeout', '取得がタイムアウトしました。', true) + } + if (error instanceof InputError) { + return failure('invalid-input', error.message, false) + } + return upstreamFailure(error instanceof Error ? error.message : '') + } +} diff --git a/src/features/posts/server-functions.ts b/src/features/posts/server-functions.ts new file mode 100644 index 0000000..85095c8 --- /dev/null +++ b/src/features/posts/server-functions.ts @@ -0,0 +1,38 @@ +import { createServerFn } from '@tanstack/react-start' +import { searchPageInputSchema, userPageInputSchema } from './inputs' +import { loadUserPage, searchPage } from './post-service' +import type { LoadResult } from './types' + +const configFailure = (): LoadResult => ({ + ok: false, + error: { + code: 'relay-config', + message: 'TWITTER_RELAY_BASE_URL を設定してください。', + retryable: false, + }, +}) + +async function reader() { + const { getBirdReader } = await import('./bird-client.server') + return getBirdReader() +} + +export const loadUserPosts = createServerFn({ method: 'GET' }) + .validator(userPageInputSchema) + .handler(async ({ data }) => { + try { + return await loadUserPage(await reader(), data) + } catch { + return configFailure() + } + }) + +export const searchPosts = createServerFn({ method: 'GET' }) + .validator(searchPageInputSchema) + .handler(async ({ data }) => { + try { + return await searchPage(await reader(), data) + } catch { + return configFailure() + } + }) diff --git a/src/features/posts/types.ts b/src/features/posts/types.ts index e62f004..ae16c56 100644 --- a/src/features/posts/types.ts +++ b/src/features/posts/types.ts @@ -1,6 +1,8 @@ import type { SearchProduct, TweetData } from '@yuta/bird' -export type Post = TweetData +export type Post = Omit & { + quotedTweet?: Post +} export type PostPage = { tweets: Post[]