From 2e293ad65b3a7dad385a9ca51619e10044951f59 Mon Sep 17 00:00:00 2001 From: yuta Date: Wed, 24 Jun 2026 20:46:34 +0900 Subject: [PATCH] Add user profile resolution --- README.md | 18 ++ src/cli/program.ts | 4 +- src/commands/users.ts | 85 +++++++- src/lib/twitter-client-constants.ts | 2 + src/lib/twitter-client-types.ts | 52 +++++ src/lib/twitter-client-user-lookup.ts | 266 ++++++++++++++++++++++- src/lib/twitter-client.ts | 3 + tests/cli-args.test.ts | 7 + tests/commands.users.test.ts | 40 ++++ tests/help-output.test.ts | 2 + tests/twitter-client.user-lookup.test.ts | 180 +++++++++++++++ 11 files changed, 656 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f688854..bb43f21 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,10 @@ bird following --user 12345678 -n 10 # by user ID bird followers -n 20 bird followers --user 12345678 -n 10 # by user ID +# Rich profile resolution +bird user @steipete --json +bird profiles @steipete 12345678 --json + # Refresh GraphQL query IDs cache (no rebuild) bird query-ids --fresh ``` @@ -192,6 +196,8 @@ Fields: - `bird list-timeline [-n count] [--all] [--max-pages n] [--cursor string] [--json]` — get tweets from a list timeline; `--max-pages` implies `--all`. - `bird following [--user ] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that you (or another user) follow; `--max-pages` requires `--all`. - `bird followers [--user ] [-n count] [--cursor string] [--all] [--max-pages n] [--json]` — list users that follow you (or another user); `--max-pages` requires `--all`. +- `bird user <@handle-or-id> [--json]` — resolve a rich user profile. +- `bird profiles <@handle-or-id...> [--json]` — resolve rich user profiles with per-target errors. - `bird about <@handle> [--json]` — get account origin and location information for a user. - `bird whoami` — print which Twitter account your relay belongs to. - `bird check` — verify relay configuration and account access. @@ -293,6 +299,18 @@ When using `--json` with `following`/`followers`, user objects include: | `profileImageUrl` | string? | Profile image URL | | `createdAt` | string? | Account creation timestamp | +When using `--json` with `user`/`profiles`, profile objects include the user fields above plus richer metadata when available: + +| Field | Type | Description | +|-------|------|-------------| +| `location` | string? | Profile location | +| `url` | string? | Expanded profile URL | +| `verified` | boolean? | Verified status | +| `verifiedType` | string? | Verification type | +| `entities` | object? | Raw profile URL/bio entities | +| `affiliation` | object? | Highlighted-label organization metadata | +| `raw` | object? | Raw GraphQL user payload | + When using `--json` with `news`/`trending`, news objects include: | Field | Type | Description | diff --git a/src/cli/program.ts b/src/cli/program.ts index 00d3f4f..fa0420a 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -31,6 +31,8 @@ export const KNOWN_COMMANDS = new Set([ 'unfollow', 'following', 'followers', + 'user', + 'profiles', 'likes', 'lists', 'list-timeline', @@ -96,7 +98,7 @@ export function createProgram(ctx: CliContext): Command { ].join('\n\n')}\n\n${ctx.colors.section('Shortcuts')}\n${[ formatExample('bird [--json]', 'Shorthand for `bird read `'), ].join('\n\n')}\n\n${ctx.colors.section('JSON Output')}\n${ctx.colors.muted( - ` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, about, lists, list-timeline, user-tweets, query-ids`, + ` Add ${ctx.colors.option('--json')} to: read, replies, thread, search, mentions, bookmarks, likes, following, followers, user, profiles, about, lists, list-timeline, user-tweets, query-ids`, )}\n${ctx.colors.muted( ` Add ${ctx.colors.option('--json-full')} to include raw API response in ${ctx.colors.argument('_raw')} field (tweet commands only)`, )}\n${ctx.colors.muted(` (Run ${ctx.colors.command('bird --help')} to see per-command flags.)`)}`, diff --git a/src/commands/users.ts b/src/commands/users.ts index a86b1e6..d46ab1d 100644 --- a/src/commands/users.ts +++ b/src/commands/users.ts @@ -2,7 +2,7 @@ import type { Command } from 'commander'; import type { CliContext } from '../cli/shared.js'; import { normalizeHandle } from '../lib/normalize-handle.js'; import { TwitterClient } from '../lib/twitter-client.js'; -import type { AboutAccountProfile, TwitterUser } from '../lib/twitter-client-types.js'; +import type { AboutAccountProfile, TwitterUser, TwitterUserProfile } from '../lib/twitter-client-types.js'; function formatAboutProfile(profile: AboutAccountProfile, ctx: CliContext, handle: string): string[] { const lines: string[] = [`${ctx.p('info')}Account information for @${handle}:`]; @@ -64,6 +64,32 @@ function printUsers(users: TwitterUser[], ctx: CliContext): void { } } +function printProfile(profile: TwitterUserProfile, ctx: CliContext): void { + console.log(`@${profile.username} (${profile.name})`); + console.log(`${ctx.l('userId')}${profile.id}`); + if (profile.description) { + console.log(` ${profile.description}`); + } + if (profile.location) { + console.log(` Location: ${profile.location}`); + } + if (profile.url) { + console.log(` URL: ${profile.url}`); + } + if (profile.followersCount !== undefined) { + console.log(` ${ctx.p('info')}${profile.followersCount.toLocaleString()} followers`); + } + if (profile.followingCount !== undefined) { + console.log(` ${ctx.p('info')}${profile.followingCount.toLocaleString()} following`); + } + if (profile.verifiedType || profile.verified) { + console.log(` Verified: ${profile.verifiedType ?? 'yes'}`); + } + if (profile.affiliation?.description) { + console.log(` Affiliation: ${profile.affiliation.description}`); + } +} + async function resolveUserIdOrExit( client: TwitterClient, requestedUserId: string | undefined, @@ -216,6 +242,63 @@ export function registerUserCommands(program: Command, ctx: CliContext): void { fetch: (client, userId, count, cursor) => client.getFollowers(userId, count, cursor), }); + program + .command('user') + .description('Resolve a rich user profile by handle or user ID') + .argument('', 'Username (with or without @) or numeric user ID') + .option('--json', 'Output as JSON') + .action(async (target: string, cmdOpts: { json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); + const result = await client.getUserProfile(target); + + if (!result.success) { + console.error(`${ctx.p('err')}Failed to resolve user: ${result.error}`); + process.exit(1); + } + if (cmdOpts.json) { + console.log(JSON.stringify(result.profile, null, 2)); + return; + } + printProfile(result.profile, ctx); + }); + + program + .command('profiles') + .description('Resolve rich user profiles by handle or user ID') + .argument('', 'Usernames or numeric user IDs') + .option('--json', 'Output as JSON') + .action(async (targets: string[], cmdOpts: { json?: boolean }) => { + const opts = program.opts(); + const timeoutMs = ctx.resolveTimeoutFromOptions(opts); + const relayBaseUrl = ctx.resolveRelayBaseUrlFromOptions(opts); + const profileName = ctx.resolveProfileNameFromOptions(opts); + const client = new TwitterClient({ relayBaseUrl, profileName, timeoutMs }); + const result = await client.getUserProfiles(targets); + const users = result.results.flatMap((item) => ('profile' in item ? [item.profile] : [])); + const errors = result.results.flatMap((item) => + 'error' in item ? [{ target: item.target, error: item.error }] : [], + ); + + if (cmdOpts.json) { + console.log(JSON.stringify({ users, errors }, null, 2)); + return; + } + for (const profile of users) { + printProfile(profile, ctx); + console.log('──────────────────────────────────────────────────'); + } + for (const error of errors) { + console.error(`${ctx.p('err')}${error.target}: ${error.error}`); + } + if (users.length === 0 && errors.length > 0) { + process.exit(1); + } + }); + program .command('likes') .description('Get your liked tweets') diff --git a/src/lib/twitter-client-constants.ts b/src/lib/twitter-client-constants.ts index fbb382f..0a5516c 100644 --- a/src/lib/twitter-client-constants.ts +++ b/src/lib/twitter-client-constants.ts @@ -39,6 +39,8 @@ export const FALLBACK_QUERY_IDS = { GenericTimelineById: 'uGSr7alSjR9v6QJAIaqSKQ', TrendHistory: 'Sj4T-jSB9pr0Mxtsc1UKZQ', AboutAccountQuery: 'zs_jFPFT78rBpXv9Z3U2YQ', + UserByScreenName: 'IGgvgiOx4QZndDHuD3x9TQ', + UserByRestId: 'VQfQ9wwYdk6j_u2O4vt64Q', } as const; export type OperationName = keyof typeof FALLBACK_QUERY_IDS; diff --git a/src/lib/twitter-client-types.ts b/src/lib/twitter-client-types.ts index 4896303..80d3218 100644 --- a/src/lib/twitter-client-types.ts +++ b/src/lib/twitter-client-types.ts @@ -334,6 +334,58 @@ export interface TwitterUser { createdAt?: string; } +export interface TwitterUserUrlEntity { + url?: string; + expanded_url?: string; + display_url?: string; +} + +export interface TwitterUserProfile { + id: string; + username: string; + name: string; + description?: string; + location?: string; + url?: string; + verified?: boolean; + verifiedType?: string; + followersCount?: number; + followingCount?: number; + profileImageUrl?: string; + createdAt?: string; + entities?: Record; + affiliation?: { + description?: string; + url?: string; + badgeUrl?: string; + }; + raw?: unknown; +} + +export type UserProfileResult = + | { + success: true; + profile: TwitterUserProfile; + } + | { + success: false; + error: string; + }; + +export type UserProfilesResult = { + success: true; + results: Array< + | { + target: string; + profile: TwitterUserProfile; + } + | { + target: string; + error: string; + } + >; +}; + export interface FollowingResult { success: boolean; users?: TwitterUser[]; diff --git a/src/lib/twitter-client-user-lookup.ts b/src/lib/twitter-client-user-lookup.ts index 97be9f9..4a952cd 100644 --- a/src/lib/twitter-client-user-lookup.ts +++ b/src/lib/twitter-client-user-lookup.ts @@ -1,7 +1,14 @@ import { normalizeHandle } from './normalize-handle.js'; import type { AbstractConstructor, Mixin, TwitterClientBase } from './twitter-client-base.js'; import { TWITTER_API_BASE } from './twitter-client-constants.js'; -import type { AboutAccountResult } from './twitter-client-types.js'; +import type { + AboutAccountResult, + TwitterUserProfile, + UserProfileResult, + UserProfilesResult, +} from './twitter-client-types.js'; + +const NUMERIC_USER_ID_PATTERN = /^[0-9]+$/; /** Result of username to userId lookup */ export interface UserLookupResult { @@ -14,9 +21,119 @@ export interface UserLookupResult { export interface TwitterClientUserLookupMethods { getUserIdByUsername(username: string): Promise; + getUserProfile(target: string): Promise; + getUserProfiles(targets: string[]): Promise; getUserAboutAccount(username: string): Promise; } +type UserProfileGraphqlResult = { + __typename?: string; + rest_id?: string; + is_blue_verified?: boolean; + avatar?: { + image_url?: string; + }; + verification_info?: unknown; + legacy?: { + screen_name?: string; + name?: string; + description?: string; + location?: string; + url?: string; + created_at?: string; + verified?: boolean; + verified_type?: string; + followers_count?: number; + friends_count?: number; + profile_image_url_https?: string; + entities?: Record; + }; + core?: { + screen_name?: string; + name?: string; + created_at?: string; + }; + location?: { + location?: string; + }; + affiliates_highlighted_label?: { + label?: { + description?: string; + url?: { url?: string }; + badge?: { url?: string }; + }; + }; + user?: UserProfileGraphqlResult; +}; + +function isNumericUserId(value: string): boolean { + return NUMERIC_USER_ID_PATTERN.test(value.trim()); +} + +function firstExpandedUrl(entities: Record | undefined): string | undefined { + const urlEntity = entities?.url; + if (!urlEntity || typeof urlEntity !== 'object') { + return undefined; + } + const urls = (urlEntity as { urls?: unknown }).urls; + if (!Array.isArray(urls)) { + return undefined; + } + const first = urls[0]; + if (!first || typeof first !== 'object') { + return undefined; + } + const expandedUrl = (first as { expanded_url?: unknown }).expanded_url; + const url = (first as { url?: unknown }).url; + return typeof expandedUrl === 'string' ? expandedUrl : typeof url === 'string' ? url : undefined; +} + +function normalizeGraphqlUserResult(result: UserProfileGraphqlResult): TwitterUserProfile | null { + const user = result.__typename === 'UserWithVisibilityResults' && result.user ? result.user : result; + if (user.__typename === 'UserUnavailable') { + return null; + } + const id = user.rest_id; + const username = user.legacy?.screen_name ?? user.core?.screen_name; + if (!id || !username) { + return null; + } + const entities = user.legacy?.entities; + const location = user.legacy?.location ?? user.location?.location; + const createdAt = user.legacy?.created_at ?? user.core?.created_at; + const label = user.affiliates_highlighted_label?.label; + const affiliation = + label?.description || label?.url?.url || label?.badge?.url + ? { + ...(label.description ? { description: label.description } : {}), + ...(label.url?.url ? { url: label.url.url } : {}), + ...(label.badge?.url ? { badgeUrl: label.badge.url } : {}), + } + : undefined; + + return { + id, + username, + name: user.legacy?.name ?? user.core?.name ?? username, + ...(user.legacy?.description ? { description: user.legacy.description } : {}), + ...(location ? { location } : {}), + ...(firstExpandedUrl(entities) ? { url: firstExpandedUrl(entities) } : {}), + ...(typeof user.is_blue_verified === 'boolean' || typeof user.legacy?.verified === 'boolean' + ? { verified: Boolean(user.is_blue_verified || user.legacy?.verified) } + : {}), + ...(user.legacy?.verified_type ? { verifiedType: user.legacy.verified_type } : {}), + ...(typeof user.legacy?.followers_count === 'number' ? { followersCount: user.legacy.followers_count } : {}), + ...(typeof user.legacy?.friends_count === 'number' ? { followingCount: user.legacy.friends_count } : {}), + ...(user.avatar?.image_url || user.legacy?.profile_image_url_https + ? { profileImageUrl: user.avatar?.image_url ?? user.legacy?.profile_image_url_https } + : {}), + ...(createdAt ? { createdAt } : {}), + ...(entities ? { entities } : {}), + ...(affiliation ? { affiliation } : {}), + raw: user, + }; +} + export function withUserLookup>( Base: TBase, ): Mixin { @@ -208,6 +325,153 @@ export function withUserLookup { + return { + hidden_profile_subscriptions_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: false, + verified_phone_label_enabled: false, + subscriptions_verification_info_is_identity_verified_enabled: true, + subscriptions_verification_info_verified_since_enabled: true, + highlights_tweets_tab_ui_enabled: true, + responsive_web_twitter_article_notes_tab_enabled: true, + subscriptions_feature_can_gift_premium: true, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + }; + } + + private buildUserByRestIdFeatures(): Record { + return { + hidden_profile_subscriptions_enabled: true, + profile_label_improvements_pcf_label_in_post_enabled: true, + responsive_web_profile_redirect_enabled: false, + rweb_tipjar_consumption_enabled: false, + verified_phone_label_enabled: false, + highlights_tweets_tab_ui_enabled: true, + responsive_web_twitter_article_notes_tab_enabled: true, + subscriptions_feature_can_gift_premium: true, + creator_subscriptions_tweet_preview_api_enabled: true, + responsive_web_graphql_skip_user_profile_image_extensions_enabled: false, + responsive_web_graphql_timeline_navigation_enabled: true, + }; + } + + private async getUserProfileByScreenName(screenName: string): Promise { + const queryId = await this.getQueryId('UserByScreenName'); + const variables = { + screen_name: screenName, + withGrokTranslatedBio: true, + }; + const params = new URLSearchParams({ + variables: JSON.stringify(variables), + features: JSON.stringify(this.buildUserByScreenNameFeatures()), + fieldToggles: JSON.stringify({ + withPayments: false, + withAuxiliaryUserLabels: true, + }), + }); + + const response = await this.fetchWithTimeout( + `${TWITTER_API_BASE}/${queryId}/UserByScreenName?${params.toString()}`, + { + method: 'GET', + headers: this.getHeaders(), + }, + ); + if (!response.ok) { + const text = await response.text(); + return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; + } + + const data = (await response.json()) as { + data?: { user?: { result?: UserProfileGraphqlResult } }; + errors?: Array<{ message?: string }>; + }; + if (data.errors?.length) { + return { success: false, error: data.errors.map((error) => error.message ?? 'GraphQL error').join(', ') }; + } + const result = data.data?.user?.result; + if (result?.__typename === 'UserUnavailable') { + return { success: false, error: `User @${screenName} not found or unavailable` }; + } + const profile = result ? normalizeGraphqlUserResult(result) : null; + if (!profile) { + return { success: false, error: `Could not parse profile for @${screenName}` }; + } + return { success: true, profile }; + } + + private async getUserProfileByRestId(userId: string): Promise { + const queryId = await this.getQueryId('UserByRestId'); + const params = new URLSearchParams({ + variables: JSON.stringify({ userId }), + features: JSON.stringify(this.buildUserByRestIdFeatures()), + }); + + const response = await this.fetchWithTimeout(`${TWITTER_API_BASE}/${queryId}/UserByRestId?${params.toString()}`, { + method: 'GET', + headers: this.getHeaders(), + }); + if (!response.ok) { + const text = await response.text(); + return { success: false, error: `HTTP ${response.status}: ${text.slice(0, 200)}` }; + } + + const data = (await response.json()) as { + data?: { user?: { result?: UserProfileGraphqlResult } }; + errors?: Array<{ message?: string }>; + }; + if (data.errors?.length) { + return { success: false, error: data.errors.map((error) => error.message ?? 'GraphQL error').join(', ') }; + } + const result = data.data?.user?.result; + if (result?.__typename === 'UserUnavailable') { + return { success: false, error: `User ${userId} not found or unavailable` }; + } + const profile = result ? normalizeGraphqlUserResult(result) : null; + if (!profile) { + return { success: false, error: `Could not parse profile for ${userId}` }; + } + return { success: true, profile }; + } + + async getUserProfile(target: string): Promise { + const trimmed = target.trim(); + if (!trimmed) { + return { success: false, error: 'User target is required' }; + } + if (isNumericUserId(trimmed)) { + return this.getUserProfileByRestId(trimmed); + } + const cleanUsername = normalizeHandle(trimmed); + if (!cleanUsername) { + return { success: false, error: `Invalid username: ${target}` }; + } + return this.getUserProfileByScreenName(cleanUsername); + } + + async getUserProfiles(targets: string[]): Promise { + const results: UserProfilesResult['results'] = []; + const seen = new Set(); + for (const target of targets) { + const trimmed = target.trim(); + if (!trimmed || seen.has(trimmed)) { + continue; + } + seen.add(trimmed); + const result = await this.getUserProfile(trimmed); + if (result.success) { + results.push({ target: trimmed, profile: result.profile }); + } else { + results.push({ target: trimmed, error: result.error }); + } + } + return { success: true, results }; + } + private async getAboutAccountQueryIds(): Promise { const primary = await this.getQueryId('AboutAccountQuery'); return Array.from(new Set([primary, 'zs_jFPFT78rBpXv9Z3U2YQ'])); diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 04894c9..d5c2ec9 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -67,5 +67,8 @@ export type { TwitterClientOptions, TwitterList, TwitterUser, + TwitterUserProfile, UploadMediaResult, + UserProfileResult, + UserProfilesResult, } from './twitter-client-types.js'; diff --git a/tests/cli-args.test.ts b/tests/cli-args.test.ts index 1927203..5a5e52a 100644 --- a/tests/cli-args.test.ts +++ b/tests/cli-args.test.ts @@ -14,6 +14,8 @@ describe('cli-args', () => { 'bookmarks', 'following', 'followers', + 'user', + 'profiles', 'likes', 'help', 'whoami', @@ -58,6 +60,11 @@ describe('cli-args', () => { expect(result.argv).toBeNull(); }); + it('does not rewrite numeric profile targets when profiles command is provided', () => { + const result = resolveCliInvocation(['profiles', '@steipete', '25401953', '--json'], known); + expect(result.argv).toBeNull(); + }); + it('does not rewrite unknown commands', () => { const result = resolveCliInvocation(['https://example.com'], known); expect(result.argv).toBeNull(); diff --git a/tests/commands.users.test.ts b/tests/commands.users.test.ts index bdf7271..ef68c81 100644 --- a/tests/commands.users.test.ts +++ b/tests/commands.users.test.ts @@ -209,4 +209,44 @@ describe('users commands', () => { expect(payload.nextCursor).toBeNull(); expect(followingSpy).toHaveBeenCalledTimes(2); }); + + it('prints a single profile as JSON', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + const profileSpy = vi.spyOn(TwitterClient.prototype, 'getUserProfile').mockResolvedValue({ + success: true, + profile: { id: '1', username: 'alpha', name: 'Alpha' }, + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'bird', 'user', '@alpha', '--json']); + + expect(profileSpy).toHaveBeenCalledWith('@alpha'); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toMatchObject({ + id: '1', + username: 'alpha', + name: 'Alpha', + }); + }); + + it('prints batch profiles with per-target errors as JSON', async () => { + const program = new Command(); + registerUserCommands(program, baseCtx); + const profilesSpy = vi.spyOn(TwitterClient.prototype, 'getUserProfiles').mockResolvedValue({ + success: true, + results: [ + { target: 'alpha', profile: { id: '1', username: 'alpha', name: 'Alpha' } }, + { target: 'missing', error: 'not found' }, + ], + }); + + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); + await program.parseAsync(['node', 'bird', 'profiles', 'alpha', 'missing', '--json']); + + expect(profilesSpy).toHaveBeenCalledWith(['alpha', 'missing']); + expect(JSON.parse(String(logSpy.mock.calls[0]?.[0]))).toEqual({ + users: [{ id: '1', username: 'alpha', name: 'Alpha' }], + errors: [{ target: 'missing', error: 'not found' }], + }); + }); }); diff --git a/tests/help-output.test.ts b/tests/help-output.test.ts index 8576269..a246460 100644 --- a/tests/help-output.test.ts +++ b/tests/help-output.test.ts @@ -17,6 +17,8 @@ describe('root help output', () => { expect(help).toContain('following'); expect(help).toContain('followers'); + expect(help).toContain('user'); + expect(help).toContain('profiles'); expect(help).toContain('likes'); expect(help).toContain('bird '); diff --git a/tests/twitter-client.user-lookup.test.ts b/tests/twitter-client.user-lookup.test.ts index 2564182..3f321c5 100644 --- a/tests/twitter-client.user-lookup.test.ts +++ b/tests/twitter-client.user-lookup.test.ts @@ -163,4 +163,184 @@ describe('TwitterClient getUserIdByUsername', () => { expect(result.userId).toBe('99999'); expect(mockFetch).toHaveBeenCalledTimes(3); }); + + it('resolves a rich profile by screen name', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'User', + rest_id: '12345', + is_blue_verified: true, + verification_info: { reason: { verified_since_msec: '1700000000000' } }, + avatar: { image_url: 'https://pbs.twimg.com/profile_images/123/avatar_normal.jpg' }, + legacy: { + screen_name: 'testuser', + name: 'Test User', + description: 'Building tools', + location: 'Internet', + url: 'https://t.co/site', + created_at: 'Mon Jan 06 00:00:00 +0000 2025', + verified: false, + verified_type: 'Business', + followers_count: 123, + friends_count: 45, + profile_image_url_https: 'https://pbs.twimg.com/profile_images/123/legacy_normal.jpg', + entities: { + url: { + urls: [ + { + url: 'https://t.co/site', + expanded_url: 'https://example.com', + display_url: 'example.com', + }, + ], + }, + description: { + urls: [ + { + url: 'https://t.co/bio', + expanded_url: 'https://bio.example', + display_url: 'bio.example', + }, + ], + }, + }, + }, + affiliates_highlighted_label: { + label: { + description: 'Example Org', + url: { url: 'https://x.com/example' }, + badge: { url: 'https://pbs.twimg.com/profile_images/org/badge.png' }, + }, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); + const result = await client.getUserProfile('@testuser'); + + expect(result).toEqual({ + success: true, + profile: expect.objectContaining({ + id: '12345', + username: 'testuser', + name: 'Test User', + description: 'Building tools', + location: 'Internet', + url: 'https://example.com', + verified: true, + verifiedType: 'Business', + followersCount: 123, + followingCount: 45, + profileImageUrl: 'https://pbs.twimg.com/profile_images/123/avatar_normal.jpg', + entities: expect.objectContaining({ + url: expect.objectContaining({ + urls: [expect.objectContaining({ expanded_url: 'https://example.com' })], + }), + }), + affiliation: { + description: 'Example Org', + url: 'https://x.com/example', + badgeUrl: 'https://pbs.twimg.com/profile_images/org/badge.png', + }, + raw: expect.any(Object), + }), + }); + }); + + it('resolves a rich profile by user id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'User', + rest_id: '42', + core: { + screen_name: 'byid', + name: 'By Id', + created_at: 'Tue Feb 01 00:00:00 +0000 2022', + }, + location: { + location: 'Vienna & London', + }, + legacy: { + description: 'Lookup by id', + followers_count: 10, + friends_count: 2, + }, + }, + }, + }, + }), + }); + + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); + const result = await client.getUserProfile('42'); + + expect(result.success).toBe(true); + expect(result.profile).toMatchObject({ + id: '42', + username: 'byid', + name: 'By Id', + description: 'Lookup by id', + location: 'Vienna & London', + createdAt: 'Tue Feb 01 00:00:00 +0000 2022', + followersCount: 10, + followingCount: 2, + }); + expect(String(mockFetch.mock.calls[0]?.[0])).toContain('/UserByRestId?'); + }); + + it('resolves profile batches with per-target errors', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'User', + rest_id: '1', + legacy: { screen_name: 'alpha', name: 'Alpha' }, + }, + }, + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + data: { + user: { + result: { + __typename: 'UserUnavailable', + }, + }, + }, + }), + }); + + const client = new TwitterClient({ relayBaseUrl: testRelayBaseUrl }); + const result = await client.getUserProfiles(['alpha', 'missing']); + + expect(result).toEqual({ + success: true, + results: [ + { target: 'alpha', profile: expect.objectContaining({ id: '1', username: 'alpha' }) }, + { target: 'missing', error: 'User @missing not found or unavailable' }, + ], + }); + }); });