import type { GetTweetResult, SearchProduct, SearchResult, TweetData, } from '@yuta/bird' import { buildSearchQuery, InputError, normalizeListTarget, normalizeTweetId, normalizeUserTarget, } from './inputs' import type { ListChoicesResult, ListPageInput, LoadError, LoadFailure, LoadResult, Post, SearchPageInput, ThreadLoadResult, ThreadPageInput, UserPageInput, } from './types' type UserLookupResult = { success: boolean userId?: string error?: string } type BirdListsResult = | { success: true lists?: Array<{ id: string name: string description?: string memberCount?: number isPrivate?: boolean }> } | { success: false; error?: string } export interface BirdReader { getLists(count?: number): Promise getTweet(tweetId: string): Promise getThreadPaged( tweetId: string, options: { cursor?: string maxPages: number pageDelayMs: number }, ): Promise 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 getAllListTimeline( listId: string, options: { cursor?: string maxPages: number }, ): Promise } const failure = ( code: LoadError['code'], message: string, retryable: boolean, ): LoadFailure => ({ ok: false, error: { code, message, retryable } }) type FailureSubject = 'user' | 'post' | 'list' const notFoundFailure: Record LoadFailure> = { user: () => failure('user-not-found', 'ユーザーが見つかりませんでした。', false), post: () => failure('post-not-found', '投稿が見つかりませんでした。', false), list: () => failure('list-not-found', 'リストが見つかりませんでした。', false), } const unavailableFailure: Record LoadFailure> = { user: () => failure('user-unavailable', 'このユーザーの投稿は取得できません。', false), post: () => failure('post-unavailable', 'この投稿は取得できません。', false), list: () => failure('list-unavailable', 'このリストは取得できません。', false), } function upstreamFailure( diagnostic: unknown = '', subject: FailureSubject = 'user', ): LoadFailure { const message = diagnostic instanceof Error ? diagnostic.message : typeof diagnostic === 'string' ? diagnostic : '' const lower = message.toLowerCase() if (lower.includes('timeout') || lower.includes('aborted')) { return failure('timeout', '取得がタイムアウトしました。', true) } if (lower.includes('not found')) { return notFoundFailure[subject]() } if (lower.includes('suspended') || lower.includes('protected')) { return unavailableFailure[subject]() } console.error('Unexpected Bird failure:', diagnostic) 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 } export async function loadListChoices( reader: BirdReader, ): Promise { try { const result = await reader.getLists(100) if (!result.success) { return upstreamFailure(result.error, 'list') } return { ok: true, lists: (result.lists ?? []).map((list) => ({ id: list.id, name: list.name, ...(list.description ? { description: list.description } : {}), ...(list.memberCount === undefined ? {} : { memberCount: list.memberCount }), ...(list.isPrivate === undefined ? {} : { isPrivate: list.isPrivate }), })), } } catch (error) { if (error instanceof Error && error.name === 'AbortError') { return failure('timeout', '取得がタイムアウトしました。', true) } return upstreamFailure(error, 'list') } } function resultPage( result: SearchResult, subject: FailureSubject = 'user', ): LoadResult { return result.success ? { ok: true, page: { tweets: result.tweets.map(publicPost), nextCursor: result.nextCursor, }, } : upstreamFailure(result.error, subject) } export async function loadThreadPage( reader: BirdReader, input: ThreadPageInput, ): Promise { try { const tweetId = normalizeTweetId(input.tweetId) let focalPost: Post | undefined let conversationId: string if (input.cursor && input.conversationId) { conversationId = normalizeTweetId(input.conversationId) } else { const focal = await reader.getTweet(tweetId) if (!focal.success || !focal.tweet) { return upstreamFailure(focal.error ?? 'Tweet not found', 'post') } focalPost = publicPost(focal.tweet) try { conversationId = normalizeTweetId( focal.tweet.conversationId ?? focal.tweet.id, ) } catch (error) { return upstreamFailure(error, 'post') } } const result = await reader.getThreadPaged(conversationId, { ...(input.cursor ? { cursor: input.cursor } : {}), maxPages: 1, pageDelayMs: 0, }) if (!result.success) { return upstreamFailure(result.error, 'post') } const tweets = result.tweets ?? [] const nextCursor = result.nextCursor && tweets.length > 0 && result.nextCursor !== input.cursor && focalPost?.replyCount !== 0 ? result.nextCursor : undefined return { ok: true, page: { tweets: tweets.map(publicPost), focalPost, conversationId, nextCursor, }, } } catch (error) { if (error instanceof InputError) { return failure('invalid-input', error.message, false) } return upstreamFailure(error, 'post') } } 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) } } export async function loadListPage( reader: BirdReader, input: ListPageInput, ): Promise { try { const listId = normalizeListTarget(input.target) return resultPage( await reader.getAllListTimeline(listId, { cursor: input.cursor, maxPages: 1, }), 'list', ) } 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, 'list') } } 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) } }