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
+47 -1
View File
@@ -1,5 +1,51 @@
import { describe, expect, it } from 'vitest'
import { buildSearchQuery, normalizeUserTarget } from './inputs'
import {
buildSearchQuery,
normalizeTweetId,
normalizeUserTarget,
threadPageInputSchema,
} from './inputs'
describe('threadPageInputSchema', () => {
it('accepts initial and complete continuation requests', () => {
expect(threadPageInputSchema.parse({ tweetId: '123' })).toEqual({
tweetId: '123',
})
expect(
threadPageInputSchema.parse({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
}),
).toEqual({
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
})
it.each([
{ tweetId: '' },
{ tweetId: 'abc' },
{ tweetId: '123', cursor: 'thread-next' },
{ tweetId: '123', conversationId: '100' },
{ tweetId: '123', conversationId: 'root', cursor: 'thread-next' },
])('rejects an invalid thread request %#', (input) => {
expect(() => threadPageInputSchema.parse(input)).toThrow()
})
})
describe('normalizeTweetId', () => {
it('keeps a decimal post ID', () => {
expect(normalizeTweetId(' 1234567890 ')).toBe('1234567890')
})
it.each(['', '123/status', '@123', '123'])('rejects %s', (input) => {
expect(() => normalizeTweetId(input)).toThrow(
'投稿 ID を確認してください。',
)
})
})
describe('normalizeUserTarget', () => {
it.each([
+26
View File
@@ -2,9 +2,27 @@ import { z } from 'zod'
const HANDLE = /^[A-Za-z0-9_]{1,15}$/
const FOLLOWS = /(?:^|\s)filter:follows(?:\s|$)/i
const TWEET_ID = /^\d{1,32}$/
const tweetIdSchema = z
.string()
.trim()
.regex(TWEET_ID, '投稿 ID を確認してください。')
export class InputError extends Error {}
export const threadPageInputSchema = z.union([
z.object({
tweetId: tweetIdSchema,
conversationId: z.undefined().optional(),
cursor: z.undefined().optional(),
}),
z.object({
tweetId: tweetIdSchema,
conversationId: tweetIdSchema,
cursor: z.string().min(1),
}),
])
export const userPageInputSchema = z.object({
target: z.string().trim().min(1).max(256),
cursor: z.string().min(1).optional(),
@@ -27,6 +45,14 @@ export const postSearchRouteSchema = z.object({
following: z.boolean().catch(false),
})
export function normalizeTweetId(raw: string): string {
const value = raw.trim()
if (!TWEET_ID.test(value)) {
throw new InputError('投稿 ID を確認してください。')
}
return value
}
function requireHandle(value: string): string {
if (!HANDLE.test(value)) {
throw new InputError('ハンドルは英数字とアンダースコアで入力してください。')
+151 -1
View File
@@ -1,7 +1,33 @@
import { describe, expect, it, vi } from 'vitest'
import { type BirdReader, loadUserPage, searchPage } from './post-service'
import {
type BirdReader,
loadThreadPage,
loadUserPage,
searchPage,
} from './post-service'
const reader = (): BirdReader => ({
getTweet: vi.fn().mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
},
}),
getThreadPaged: vi.fn().mockResolvedValue({
success: true,
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
},
],
nextCursor: 'thread-next',
}),
getUserIdByUsername: vi
.fn()
.mockResolvedValue({ success: true, userId: '42' }),
@@ -19,6 +45,130 @@ const reader = (): BirdReader => ({
}),
})
describe('loadThreadPage', () => {
it('resolves the focal post and fetches one root conversation page', async () => {
const client = reader()
const result = await loadThreadPage(client, { tweetId: '123' })
expect(client.getTweet).toHaveBeenCalledWith('123')
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
maxPages: 1,
pageDelayMs: 0,
})
expect(result).toEqual({
ok: true,
page: {
focalPost: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
},
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
},
],
conversationId: '100',
nextCursor: 'thread-next',
},
})
})
it('uses the carried root for exactly one continuation read', async () => {
const client = reader()
const result = await loadThreadPage(client, {
tweetId: '123',
conversationId: '100',
cursor: 'thread-next',
})
expect(client.getTweet).not.toHaveBeenCalled()
expect(client.getThreadPaged).toHaveBeenCalledOnce()
expect(client.getThreadPaged).toHaveBeenCalledWith('100', {
cursor: 'thread-next',
maxPages: 1,
pageDelayMs: 0,
})
expect(result).toMatchObject({
ok: true,
page: { conversationId: '100', focalPost: undefined },
})
})
it('removes raw data from focal, conversation, and quotes', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: true,
tweet: {
id: '123',
text: 'focal',
conversationId: '100',
author: { username: 'focus', name: 'Focus' },
quotedTweet: {
id: '90',
text: 'quote',
author: { username: 'quote', name: 'Quote' },
_raw: { rest_id: 'private-quote' },
},
_raw: { rest_id: 'private-focal' },
},
})
vi.mocked(client.getThreadPaged).mockResolvedValue({
success: true,
tweets: [
{
id: '100',
text: 'root',
conversationId: '100',
author: { username: 'root', name: 'Root' },
_raw: { rest_id: 'private-thread' },
},
],
})
const result = await loadThreadPage(client, { tweetId: '123' })
expect(JSON.stringify(result)).not.toContain('private-')
})
it('returns a safe non-retryable missing-post error', async () => {
const client = reader()
vi.mocked(client.getTweet).mockResolvedValue({
success: false,
error: 'Tweet not found: private relay detail',
})
expect(await loadThreadPage(client, { tweetId: '404' })).toEqual({
ok: false,
error: {
code: 'post-not-found',
message: '投稿が見つかりませんでした。',
retryable: false,
},
})
expect(client.getThreadPaged).not.toHaveBeenCalled()
})
it('rejects an invalid ID without a Bird call', async () => {
const client = reader()
expect(await loadThreadPage(client, { tweetId: 'not-an-id' })).toEqual({
ok: false,
error: {
code: 'invalid-input',
message: '投稿 ID を確認してください。',
retryable: false,
},
})
expect(client.getTweet).not.toHaveBeenCalled()
expect(client.getThreadPaged).not.toHaveBeenCalled()
})
})
describe('loadUserPage', () => {
it('resolves a handle and fetches exactly one page', async () => {
const client = reader()
+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,
+26 -3
View File
@@ -9,8 +9,15 @@ export type PostPage = {
nextCursor?: string
}
export type ThreadPage = PostPage & {
focalPost?: Post
conversationId: string
}
export type LoadErrorCode =
| 'invalid-input'
| 'post-not-found'
| 'post-unavailable'
| 'user-not-found'
| 'user-unavailable'
| 'relay-config'
@@ -23,9 +30,25 @@ export type LoadError = {
retryable: boolean
}
export type LoadResult =
| { ok: true; page: PostPage }
| { ok: false; error: LoadError }
export type LoadFailure = { ok: false; error: LoadError }
export type LoadResult<TPage extends PostPage = PostPage> =
| { ok: true; page: TPage }
| LoadFailure
export type ThreadLoadResult = LoadResult<ThreadPage>
export type ThreadPageInput =
| {
tweetId: string
conversationId?: never
cursor?: never
}
| {
tweetId: string
conversationId: string
cursor: string
}
export type UserPageInput = {
target: string