From 1eb2733b38d8249ef33d278da5e2a6ec4f67a878 Mon Sep 17 00:00:00 2001 From: yutakobayashidev Date: Tue, 14 Jul 2026 00:14:54 +0900 Subject: [PATCH] feat: load tweet conversations --- src/features/posts/inputs.test.ts | 48 +++++++- src/features/posts/inputs.ts | 26 ++++ src/features/posts/post-service.test.ts | 152 +++++++++++++++++++++++- src/features/posts/post-service.ts | 97 +++++++++++++-- src/features/posts/types.ts | 29 ++++- 5 files changed, 337 insertions(+), 15 deletions(-) diff --git a/src/features/posts/inputs.test.ts b/src/features/posts/inputs.test.ts index 01281a9..da1aefc 100644 --- a/src/features/posts/inputs.test.ts +++ b/src/features/posts/inputs.test.ts @@ -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([ diff --git a/src/features/posts/inputs.ts b/src/features/posts/inputs.ts index 92451eb..04597a4 100644 --- a/src/features/posts/inputs.ts +++ b/src/features/posts/inputs.ts @@ -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('ハンドルは英数字とアンダースコアで入力してください。') diff --git a/src/features/posts/post-service.test.ts b/src/features/posts/post-service.test.ts index d8ebaaa..a09ec8b 100644 --- a/src/features/posts/post-service.test.ts +++ b/src/features/posts/post-service.test.ts @@ -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() diff --git a/src/features/posts/post-service.ts b/src/features/posts/post-service.ts index 51457e8..a06f94b 100644 --- a/src/features/posts/post-service.ts +++ b/src/features/posts/post-service.ts @@ -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 + getThreadPaged( + tweetId: string, + options: { + cursor?: string + maxPages: number + pageDelayMs: number + }, + ): Promise getUserIdByUsername(username: string): Promise 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 { + 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, diff --git a/src/features/posts/types.ts b/src/features/posts/types.ts index ae16c56..1f63261 100644 --- a/src/features/posts/types.ts +++ b/src/features/posts/types.ts @@ -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 = + | { ok: true; page: TPage } + | LoadFailure + +export type ThreadLoadResult = LoadResult + +export type ThreadPageInput = + | { + tweetId: string + conversationId?: never + cursor?: never + } + | { + tweetId: string + conversationId: string + cursor: string + } export type UserPageInput = { target: string