feat: load tweet conversations

This commit is contained in:
2026-07-14 00:14:54 +09:00
parent 45cee54cff
commit 1eb2733b38
5 changed files with 337 additions and 15 deletions
+87 -10
View File
@@ -1,10 +1,23 @@
import type { SearchProduct, SearchResult, TweetData } from '@yuta/bird'
import { buildSearchQuery, InputError, normalizeUserTarget } from './inputs'
import type {
GetTweetResult,
SearchProduct,
SearchResult,
TweetData,
} from '@yuta/bird'
import {
buildSearchQuery,
InputError,
normalizeTweetId,
normalizeUserTarget,
} from './inputs'
import type {
LoadError,
LoadFailure,
LoadResult,
Post,
SearchPageInput,
ThreadLoadResult,
ThreadPageInput,
UserPageInput,
} from './types'
@@ -15,6 +28,15 @@ type UserLookupResult = {
}
export interface BirdReader {
getTweet(tweetId: string): Promise<GetTweetResult>
getThreadPaged(
tweetId: string,
options: {
cursor?: string
maxPages: number
pageDelayMs: number
},
): Promise<SearchResult>
getUserIdByUsername(username: string): Promise<UserLookupResult>
getUserTweetsPaged(
userId: string,
@@ -39,9 +61,12 @@ const failure = (
code: LoadError['code'],
message: string,
retryable: boolean,
): LoadResult => ({ ok: false, error: { code, message, retryable } })
): LoadFailure => ({ ok: false, error: { code, message, retryable } })
function upstreamFailure(diagnostic: unknown = ''): LoadResult {
function upstreamFailure(
diagnostic: unknown = '',
subject: 'user' | 'post' = 'user',
): LoadFailure {
const message =
diagnostic instanceof Error
? diagnostic.message
@@ -53,14 +78,18 @@ function upstreamFailure(diagnostic: unknown = ''): LoadResult {
return failure('timeout', '取得がタイムアウトしました。', true)
}
if (lower.includes('not found')) {
return failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
return subject === 'post'
? failure('post-not-found', '投稿が見つかりませんでした。', false)
: failure('user-not-found', 'ユーザーが見つかりませんでした。', false)
}
if (lower.includes('suspended') || lower.includes('protected')) {
return failure(
'user-unavailable',
'このユーザーの投稿は取得できません。',
false,
)
return subject === 'post'
? failure('post-unavailable', 'この投稿は取得できません。', false)
: failure(
'user-unavailable',
'このユーザーの投稿は取得できません。',
false,
)
}
console.error('Unexpected Bird failure:', diagnostic)
return failure('upstream', 'X から投稿を取得できませんでした。', true)
@@ -87,6 +116,54 @@ function resultPage(result: SearchResult): LoadResult {
: upstreamFailure(result.error)
}
export async function loadThreadPage(
reader: BirdReader,
input: ThreadPageInput,
): Promise<ThreadLoadResult> {
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)
conversationId = normalizeTweetId(
focal.tweet.conversationId ?? focal.tweet.id,
)
}
const result = await reader.getThreadPaged(conversationId, {
...(input.cursor ? { cursor: input.cursor } : {}),
maxPages: 1,
pageDelayMs: 0,
})
if (!result.success) {
return upstreamFailure(result.error, 'post')
}
return {
ok: true,
page: {
tweets: (result.tweets ?? []).map(publicPost),
focalPost,
conversationId,
nextCursor: result.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,