agent-twitter-summary
Version:
A twitter client for agents
1 lines • 454 kB
Source Map (JSON)
{"version":3,"file":"index.cjs","sources":["../../../src/errors.ts","../../../src/platform/index.ts","../../../src/requests.ts","../../../src/api.ts","../../../src/auth.ts","../../../src/profile.ts","../../../src/auth-user.ts","../../../src/timeline-async.ts","../../../src/type-util.ts","../../../src/timeline-tweet-util.ts","../../../src/timeline-v2.ts","../../../src/timeline-search.ts","../../../src/search.ts","../../../src/timeline-relationship.ts","../../../src/relationships.ts","../../../src/trends.ts","../../../src/api-data.ts","../../../src/timeline-list.ts","../../../src/tweets.ts","../../../src/timeline-home.ts","../../../src/timeline-following.ts","../../../src/messages.ts","../../../src/spaces.ts","../../../src/scraper.ts","../../../src/summary.ts","../../../src/spaces/core/ChatClient.ts","../../../src/spaces/core/JanusAudio.ts","../../../src/spaces/core/JanusClient.ts","../../../src/spaces/utils.ts","../../../src/spaces/logger.ts","../../../src/spaces/core/Space.ts","../../../src/spaces/plugins/SttTtsPlugin.ts","../../../src/spaces/plugins/RecordToDiskPlugin.ts","../../../src/spaces/plugins/MonitorAudioPlugin.ts","../../../src/spaces/plugins/IdleMonitorPlugin.ts","../../../src/spaces/plugins/HlsRecordPlugin.ts","../../../src/platform/node/randomize-ciphers.ts","../../../src/platform/node/index.ts"],"sourcesContent":["export class ApiError extends Error {\n private constructor(\n readonly response: Response,\n readonly data: any,\n message: string,\n ) {\n super(message);\n }\n\n static async fromResponse(response: Response) {\n // Try our best to parse the result, but don't bother if we can't\n let data: string | object | undefined = undefined;\n try {\n data = await response.json();\n } catch {\n try {\n data = await response.text();\n } catch {}\n }\n\n return new ApiError(response, data, `Response status: ${response.status}`);\n }\n}\n\ninterface Position {\n line: number;\n column: number;\n}\n\ninterface TraceInfo {\n trace_id: string;\n}\n\ninterface TwitterApiErrorExtensions {\n code?: number;\n kind?: string;\n name?: string;\n source?: string;\n tracing?: TraceInfo;\n}\n\nexport interface TwitterApiErrorRaw extends TwitterApiErrorExtensions {\n message?: string;\n locations?: Position[];\n path?: string[];\n extensions?: TwitterApiErrorExtensions;\n}\n","import { PlatformExtensions, genericPlatform } from './platform-interface';\n\nexport * from './platform-interface';\n\ndeclare const PLATFORM_NODE: boolean;\ndeclare const PLATFORM_NODE_JEST: boolean;\n\nexport class Platform implements PlatformExtensions {\n async randomizeCiphers() {\n const platform = await Platform.importPlatform();\n await platform?.randomizeCiphers();\n }\n\n private static async importPlatform(): Promise<null | PlatformExtensions> {\n if (PLATFORM_NODE) {\n const { platform } = await import('./node/index.js');\n return platform as PlatformExtensions;\n } else if (PLATFORM_NODE_JEST) {\n // Jest gets unhappy when using an await import here, so we just use require instead.\n // eslint-disable-next-line @typescript-eslint/no-var-requires\n const { platform } = require('./node');\n return platform as PlatformExtensions;\n }\n\n return genericPlatform;\n }\n}\n","import { Cookie, CookieJar } from 'tough-cookie';\nimport setCookie from 'set-cookie-parser';\nimport type { Headers as HeadersPolyfill } from 'headers-polyfill';\n\n/**\n * Updates a cookie jar with the Set-Cookie headers from the provided Headers instance.\n * @param cookieJar The cookie jar to update.\n * @param headers The response headers to populate the cookie jar with.\n */\nexport async function updateCookieJar(\n cookieJar: CookieJar,\n headers: Headers | HeadersPolyfill,\n) {\n const setCookieHeader = headers.get('set-cookie');\n if (setCookieHeader) {\n const cookies = setCookie.splitCookiesString(setCookieHeader);\n for (const cookie of cookies.map((c) => Cookie.parse(c))) {\n if (!cookie) continue;\n await cookieJar.setCookie(\n cookie,\n `${cookie.secure ? 'https' : 'http'}://${cookie.domain}${cookie.path}`,\n );\n }\n } else if (typeof document !== 'undefined') {\n for (const cookie of document.cookie.split(';')) {\n const hardCookie = Cookie.parse(cookie);\n if (hardCookie) {\n await cookieJar.setCookie(hardCookie, document.location.toString());\n }\n }\n }\n}\n","import { TwitterAuth } from './auth';\nimport { ApiError } from './errors';\nimport { Platform, PlatformExtensions } from './platform';\nimport { updateCookieJar } from './requests';\nimport { Headers } from 'headers-polyfill';\n\n// For some reason using Parameters<typeof fetch> reduces the request transform function to\n// `(url: string) => string` in tests.\ntype FetchParameters = [input: RequestInfo | URL, init?: RequestInit];\n\nexport interface FetchTransformOptions {\n /**\n * Transforms the request options before a request is made. This executes after all of the default\n * parameters have been configured, and is stateless. It is safe to return new request options\n * objects.\n * @param args The request options.\n * @returns The transformed request options.\n */\n request: (\n ...args: FetchParameters\n ) => FetchParameters | Promise<FetchParameters>;\n\n /**\n * Transforms the response after a request completes. This executes immediately after the request\n * completes, and is stateless. It is safe to return a new response object.\n * @param response The response object.\n * @returns The transformed response object.\n */\n response: (response: Response) => Response | Promise<Response>;\n}\n\nexport const bearerToken =\n 'AAAAAAAAAAAAAAAAAAAAAFQODgEAAAAAVHTp76lzh3rFzcHbmHVvQxYYpTw%3DckAlMINMjmCwxUcaXbAN4XqJVdgMJaHqNOFgPMK0zN1qLqLQCF';\n\n/**\n * An API result container.\n */\nexport type RequestApiResult<T> =\n | { success: true; value: T }\n | { success: false; err: Error };\n\n/**\n * Used internally to send HTTP requests to the Twitter API.\n * @internal\n * @param url - The URL to send the request to.\n * @param auth - The instance of {@link TwitterAuth} that will be used to authorize this request.\n * @param method - The HTTP method used when sending this request.\n */\nexport async function requestApi<T>(\n url: string,\n auth: TwitterAuth,\n method: 'GET' | 'POST' = 'GET',\n platform: PlatformExtensions = new Platform(),\n): Promise<RequestApiResult<T>> {\n const headers = new Headers();\n await auth.installTo(headers, url);\n await platform.randomizeCiphers();\n\n let res: Response;\n do {\n try {\n res = await auth.fetch(url, {\n method,\n headers,\n credentials: 'include',\n });\n } catch (err) {\n if (!(err instanceof Error)) {\n throw err;\n }\n\n return {\n success: false,\n err: new Error('Failed to perform request.'),\n };\n }\n\n await updateCookieJar(auth.cookieJar(), res.headers);\n\n if (res.status === 429) {\n /*\n Known headers at this point:\n - x-rate-limit-limit: Maximum number of requests per time period?\n - x-rate-limit-reset: UNIX timestamp when the current rate limit will be reset.\n - x-rate-limit-remaining: Number of requests remaining in current time period?\n */\n const xRateLimitRemaining = res.headers.get('x-rate-limit-remaining');\n const xRateLimitReset = res.headers.get('x-rate-limit-reset');\n if (xRateLimitRemaining == '0' && xRateLimitReset) {\n const currentTime = new Date().valueOf() / 1000;\n const timeDeltaMs = 1000 * (parseInt(xRateLimitReset) - currentTime);\n\n // I have seen this block for 800s (~13 *minutes*)\n await new Promise((resolve) => setTimeout(resolve, timeDeltaMs));\n }\n }\n } while (res.status === 429);\n\n if (!res.ok) {\n return {\n success: false,\n err: await ApiError.fromResponse(res),\n };\n }\n\n const value: T = await res.json();\n if (res.headers.get('x-rate-limit-incoming') == '0') {\n auth.deleteToken();\n return { success: true, value };\n } else {\n return { success: true, value };\n }\n}\n\n/** @internal */\nexport function addApiFeatures(o: object) {\n return {\n ...o,\n rweb_lists_timeline_redesign_enabled: true,\n responsive_web_graphql_exclude_directive_enabled: true,\n verified_phone_label_enabled: false,\n creator_subscriptions_tweet_preview_api_enabled: true,\n responsive_web_graphql_timeline_navigation_enabled: true,\n responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,\n tweetypie_unmention_optimization_enabled: true,\n responsive_web_edit_tweet_api_enabled: true,\n graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,\n view_counts_everywhere_api_enabled: true,\n longform_notetweets_consumption_enabled: true,\n tweet_awards_web_tipping_enabled: false,\n freedom_of_speech_not_reach_fetch_enabled: true,\n standardized_nudges_misinfo: true,\n longform_notetweets_rich_text_read_enabled: true,\n responsive_web_enhance_cards_enabled: false,\n subscriptions_verification_info_enabled: true,\n subscriptions_verification_info_reason_enabled: true,\n subscriptions_verification_info_verified_since_enabled: true,\n super_follow_badge_privacy_enabled: false,\n super_follow_exclusive_tweet_notifications_enabled: false,\n super_follow_tweet_api_enabled: false,\n super_follow_user_api_enabled: false,\n android_graphql_skip_api_media_color_palette: false,\n creator_subscriptions_subscription_count_enabled: false,\n blue_business_profile_image_shape_enabled: false,\n unified_cards_ad_metadata_container_dynamic_card_content_query_enabled:\n false,\n };\n}\n\nexport function addApiParams(\n params: URLSearchParams,\n includeTweetReplies: boolean,\n): URLSearchParams {\n params.set('include_profile_interstitial_type', '1');\n params.set('include_blocking', '1');\n params.set('include_blocked_by', '1');\n params.set('include_followed_by', '1');\n params.set('include_want_retweets', '1');\n params.set('include_mute_edge', '1');\n params.set('include_can_dm', '1');\n params.set('include_can_media_tag', '1');\n params.set('include_ext_has_nft_avatar', '1');\n params.set('include_ext_is_blue_verified', '1');\n params.set('include_ext_verified_type', '1');\n params.set('skip_status', '1');\n params.set('cards_platform', 'Web-12');\n params.set('include_cards', '1');\n params.set('include_ext_alt_text', 'true');\n params.set('include_ext_limited_action_results', 'false');\n params.set('include_quote_count', 'true');\n params.set('include_reply_count', '1');\n params.set('tweet_mode', 'extended');\n params.set('include_ext_collab_control', 'true');\n params.set('include_ext_views', 'true');\n params.set('include_entities', 'true');\n params.set('include_user_entities', 'true');\n params.set('include_ext_media_color', 'true');\n params.set('include_ext_media_availability', 'true');\n params.set('include_ext_sensitive_media_warning', 'true');\n params.set('include_ext_trusted_friends_metadata', 'true');\n params.set('send_error_codes', 'true');\n params.set('simple_quoted_tweet', 'true');\n params.set('include_tweet_replies', `${includeTweetReplies}`);\n params.set(\n 'ext',\n 'mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,birdwatchPivot,enrichments,superFollowMetadata,unmentionInfo,editControl,collab_control,vibe',\n );\n return params;\n}\n","import { Cookie, CookieJar, MemoryCookieStore } from 'tough-cookie';\nimport { updateCookieJar } from './requests';\nimport { Headers } from 'headers-polyfill';\nimport { FetchTransformOptions } from './api';\nimport { TwitterApi } from 'twitter-api-v2';\nimport { Profile } from './profile';\n\nexport interface TwitterAuthOptions {\n fetch: typeof fetch;\n transform: Partial<FetchTransformOptions>;\n}\n\nexport interface TwitterAuth {\n fetch: typeof fetch;\n\n /**\n * Returns the current cookie jar.\n */\n cookieJar(): CookieJar;\n\n /**\n * Logs into a Twitter account using the v2 API\n */\n loginWithV2(\n appKey: string,\n appSecret: string,\n accessToken: string,\n accessSecret: string,\n ): void;\n\n /**\n * Get v2 API client if it exists\n */\n getV2Client(): TwitterApi | null;\n\n /**\n * Returns if a user is logged-in to Twitter through this instance.\n * @returns `true` if a user is logged-in; otherwise `false`.\n */\n isLoggedIn(): Promise<boolean>;\n\n /**\n * Fetches the current user's profile.\n */\n me(): Promise<Profile | undefined>;\n\n /**\n * Logs into a Twitter account.\n * @param username The username to log in with.\n * @param password The password to log in with.\n * @param email The email to log in with, if you have email confirmation enabled.\n * @param twoFactorSecret The secret to generate two factor authentication tokens with, if you have two factor authentication enabled.\n */\n login(\n username: string,\n password: string,\n email?: string,\n twoFactorSecret?: string,\n ): Promise<void>;\n\n /**\n * Logs out of the current session.\n */\n logout(): Promise<void>;\n\n /**\n * Deletes the current guest token token.\n */\n deleteToken(): void;\n\n /**\n * Returns if the authentication state has a token.\n * @returns `true` if the authentication state has a token; `false` otherwise.\n */\n hasToken(): boolean;\n\n /**\n * Returns the time that authentication was performed.\n * @returns The time at which the authentication token was created, or `null` if it hasn't been created yet.\n */\n authenticatedAt(): Date | null;\n\n /**\n * Installs the authentication information into a headers-like object. If needed, the\n * authentication token will be updated from the API automatically.\n * @param headers A Headers instance representing a request's headers.\n */\n installTo(headers: Headers, url: string): Promise<void>;\n}\n\n/**\n * Wraps the provided fetch function with transforms.\n * @param fetchFn The fetch function.\n * @param transform The transform options.\n * @returns The input fetch function, wrapped with the provided transforms.\n */\nfunction withTransform(\n fetchFn: typeof fetch,\n transform?: Partial<FetchTransformOptions>,\n): typeof fetch {\n return async (input, init) => {\n const fetchArgs = (await transform?.request?.(input, init)) ?? [\n input,\n init,\n ];\n const res = await fetchFn(...fetchArgs);\n return (await transform?.response?.(res)) ?? res;\n };\n}\n\n/**\n * A guest authentication token manager. Automatically handles token refreshes.\n */\nexport class TwitterGuestAuth implements TwitterAuth {\n protected bearerToken: string;\n protected jar: CookieJar;\n protected guestToken?: string;\n protected guestCreatedAt?: Date;\n protected v2Client: TwitterApi | null;\n\n fetch: typeof fetch;\n\n constructor(\n bearerToken: string,\n protected readonly options?: Partial<TwitterAuthOptions>,\n ) {\n this.fetch = withTransform(options?.fetch ?? fetch, options?.transform);\n this.bearerToken = bearerToken;\n this.jar = new CookieJar();\n this.v2Client = null;\n }\n\n cookieJar(): CookieJar {\n return this.jar;\n }\n\n getV2Client(): TwitterApi | null {\n return this.v2Client ?? null;\n }\n\n loginWithV2(\n appKey: string,\n appSecret: string,\n accessToken: string,\n accessSecret: string,\n ): void {\n const v2Client = new TwitterApi({\n appKey,\n appSecret,\n accessToken,\n accessSecret,\n });\n this.v2Client = v2Client;\n }\n\n isLoggedIn(): Promise<boolean> {\n return Promise.resolve(false);\n }\n\n async me(): Promise<Profile | undefined> {\n return undefined;\n }\n\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n login(_username: string, _password: string, _email?: string): Promise<void> {\n return this.updateGuestToken();\n }\n\n logout(): Promise<void> {\n this.deleteToken();\n this.jar = new CookieJar();\n return Promise.resolve();\n }\n\n deleteToken() {\n delete this.guestToken;\n delete this.guestCreatedAt;\n }\n\n hasToken(): boolean {\n return this.guestToken != null;\n }\n\n authenticatedAt(): Date | null {\n if (this.guestCreatedAt == null) {\n return null;\n }\n\n return new Date(this.guestCreatedAt);\n }\n\n async installTo(headers: Headers): Promise<void> {\n if (this.shouldUpdate()) {\n await this.updateGuestToken();\n }\n\n const token = this.guestToken;\n if (token == null) {\n throw new Error('Authentication token is null or undefined.');\n }\n\n headers.set('authorization', `Bearer ${this.bearerToken}`);\n headers.set('x-guest-token', token);\n\n const cookies = await this.getCookies();\n const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');\n if (xCsrfToken) {\n headers.set('x-csrf-token', xCsrfToken.value);\n }\n\n headers.set('cookie', await this.getCookieString());\n }\n\n protected getCookies(): Promise<Cookie[]> {\n return this.jar.getCookies(this.getCookieJarUrl());\n }\n\n protected getCookieString(): Promise<string> {\n return this.jar.getCookieString(this.getCookieJarUrl());\n }\n\n protected async removeCookie(key: string): Promise<void> {\n //@ts-expect-error don't care\n const store: MemoryCookieStore = this.jar.store;\n const cookies = await this.jar.getCookies(this.getCookieJarUrl());\n for (const cookie of cookies) {\n if (!cookie.domain || !cookie.path) continue;\n store.removeCookie(cookie.domain, cookie.path, key);\n\n if (typeof document !== 'undefined') {\n document.cookie = `${cookie.key}=; Max-Age=0; path=${cookie.path}; domain=${cookie.domain}`;\n }\n }\n }\n\n private getCookieJarUrl(): string {\n return typeof document !== 'undefined'\n ? document.location.toString()\n : 'https://twitter.com';\n }\n\n /**\n * Updates the authentication state with a new guest token from the Twitter API.\n */\n protected async updateGuestToken() {\n const guestActivateUrl = 'https://api.twitter.com/1.1/guest/activate.json';\n\n const headers = new Headers({\n Authorization: `Bearer ${this.bearerToken}`,\n Cookie: await this.getCookieString(),\n });\n\n const res = await this.fetch(guestActivateUrl, {\n method: 'POST',\n headers: headers,\n referrerPolicy: 'no-referrer',\n });\n\n await updateCookieJar(this.jar, res.headers);\n\n if (!res.ok) {\n throw new Error(await res.text());\n }\n\n const o = await res.json();\n if (o == null || o['guest_token'] == null) {\n throw new Error('guest_token not found.');\n }\n\n const newGuestToken = o['guest_token'];\n if (typeof newGuestToken !== 'string') {\n throw new Error('guest_token was not a string.');\n }\n\n this.guestToken = newGuestToken;\n this.guestCreatedAt = new Date();\n }\n\n /**\n * Returns if the authentication token needs to be updated or not.\n * @returns `true` if the token needs to be updated; `false` otherwise.\n */\n private shouldUpdate(): boolean {\n return (\n !this.hasToken() ||\n (this.guestCreatedAt != null &&\n this.guestCreatedAt <\n new Date(new Date().valueOf() - 3 * 60 * 60 * 1000))\n );\n }\n}\n","import stringify from 'json-stable-stringify';\nimport { requestApi, RequestApiResult } from './api';\nimport { TwitterAuth } from './auth';\nimport { TwitterApiErrorRaw } from './errors';\n\nexport interface LegacyUserRaw {\n created_at?: string;\n description?: string;\n entities?: {\n url?: {\n urls?: {\n expanded_url?: string;\n }[];\n };\n };\n favourites_count?: number;\n followers_count?: number;\n friends_count?: number;\n media_count?: number;\n statuses_count?: number;\n id_str?: string;\n listed_count?: number;\n name?: string;\n location: string;\n geo_enabled?: boolean;\n pinned_tweet_ids_str?: string[];\n profile_background_color?: string;\n profile_banner_url?: string;\n profile_image_url_https?: string;\n protected?: boolean;\n screen_name?: string;\n verified?: boolean;\n has_custom_timelines?: boolean;\n has_extended_profile?: boolean;\n url?: string;\n can_dm?: boolean;\n}\n\n/**\n * A parsed profile object.\n */\nexport interface Profile {\n avatar?: string;\n banner?: string;\n biography?: string;\n birthday?: string;\n followersCount?: number;\n followingCount?: number;\n friendsCount?: number;\n mediaCount?: number;\n statusesCount?: number;\n isPrivate?: boolean;\n isVerified?: boolean;\n isBlueVerified?: boolean;\n joined?: Date;\n likesCount?: number;\n listedCount?: number;\n location: string;\n name?: string;\n pinnedTweetIds?: string[];\n tweetsCount?: number;\n url?: string;\n userId?: string;\n username?: string;\n website?: string;\n canDm?: boolean;\n}\n\nexport interface UserRaw {\n data: {\n user: {\n result: {\n rest_id?: string;\n is_blue_verified?: boolean;\n legacy: LegacyUserRaw;\n };\n };\n };\n errors?: TwitterApiErrorRaw[];\n}\n\nfunction getAvatarOriginalSizeUrl(avatarUrl: string | undefined) {\n return avatarUrl ? avatarUrl.replace('_normal', '') : undefined;\n}\n\nexport function parseProfile(\n user: LegacyUserRaw,\n isBlueVerified?: boolean,\n): Profile {\n const profile: Profile = {\n avatar: getAvatarOriginalSizeUrl(user.profile_image_url_https),\n banner: user.profile_banner_url,\n biography: user.description,\n followersCount: user.followers_count,\n followingCount: user.friends_count,\n friendsCount: user.friends_count,\n mediaCount: user.media_count,\n isPrivate: user.protected ?? false,\n isVerified: user.verified,\n likesCount: user.favourites_count,\n listedCount: user.listed_count,\n location: user.location,\n name: user.name,\n pinnedTweetIds: user.pinned_tweet_ids_str,\n tweetsCount: user.statuses_count,\n url: `https://twitter.com/${user.screen_name}`,\n userId: user.id_str,\n username: user.screen_name,\n isBlueVerified: isBlueVerified ?? false,\n canDm: user.can_dm,\n };\n\n if (user.created_at != null) {\n profile.joined = new Date(Date.parse(user.created_at));\n }\n\n const urls = user.entities?.url?.urls;\n if (urls?.length != null && urls?.length > 0) {\n profile.website = urls[0].expanded_url;\n }\n\n return profile;\n}\n\nexport async function getProfile(\n username: string,\n auth: TwitterAuth,\n): Promise<RequestApiResult<Profile>> {\n const params = new URLSearchParams();\n params.set(\n 'variables',\n stringify({\n screen_name: username,\n withSafetyModeUserFields: true,\n }) ?? '',\n );\n\n params.set(\n 'features',\n stringify({\n hidden_profile_likes_enabled: false,\n hidden_profile_subscriptions_enabled: false, // Auth-restricted\n responsive_web_graphql_exclude_directive_enabled: true,\n verified_phone_label_enabled: false,\n subscriptions_verification_info_is_identity_verified_enabled: false,\n subscriptions_verification_info_verified_since_enabled: true,\n highlights_tweets_tab_ui_enabled: true,\n creator_subscriptions_tweet_preview_api_enabled: true,\n responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,\n responsive_web_graphql_timeline_navigation_enabled: true,\n }) ?? '',\n );\n\n params.set('fieldToggles', stringify({ withAuxiliaryUserLabels: false }) ?? '');\n\n const res = await requestApi<UserRaw>(\n `https://twitter.com/i/api/graphql/G3KGOASz96M-Qu0nwmGXNg/UserByScreenName?${params.toString()}`,\n auth,\n );\n if (!res.success) {\n return res;\n }\n\n const { value } = res;\n const { errors } = value;\n if (errors != null && errors.length > 0) {\n return {\n success: false,\n err: new Error(errors[0].message),\n };\n }\n\n if (!value.data || !value.data.user || !value.data.user.result) {\n return {\n success: false,\n err: new Error('User not found.'),\n };\n }\n const { result: user } = value.data.user;\n const { legacy } = user;\n\n if (user.rest_id == null || user.rest_id.length === 0) {\n return {\n success: false,\n err: new Error('rest_id not found.'),\n };\n }\n\n legacy.id_str = user.rest_id;\n\n if (legacy.screen_name == null || legacy.screen_name.length === 0) {\n return {\n success: false,\n err: new Error(`Either ${username} does not exist or is private.`),\n };\n }\n\n return {\n success: true,\n value: parseProfile(user.legacy, user.is_blue_verified),\n };\n}\n\nconst idCache = new Map<string, string>();\n\nexport async function getScreenNameByUserId(\n userId: string,\n auth: TwitterAuth,\n): Promise<RequestApiResult<string>> {\n const params = new URLSearchParams();\n params.set(\n 'variables',\n stringify({\n userId: userId,\n withSafetyModeUserFields: true,\n }) ?? '',\n );\n\n params.set(\n 'features',\n stringify({\n hidden_profile_subscriptions_enabled: true,\n rweb_tipjar_consumption_enabled: true,\n responsive_web_graphql_exclude_directive_enabled: true,\n verified_phone_label_enabled: false,\n highlights_tweets_tab_ui_enabled: true,\n responsive_web_twitter_article_notes_tab_enabled: true,\n subscriptions_feature_can_gift_premium: false,\n creator_subscriptions_tweet_preview_api_enabled: true,\n responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,\n responsive_web_graphql_timeline_navigation_enabled: true,\n }) ?? '',\n );\n\n const res = await requestApi<UserRaw>(\n `https://twitter.com/i/api/graphql/xf3jd90KKBCUxdlI_tNHZw/UserByRestId?${params.toString()}`,\n auth,\n );\n\n if (!res.success) {\n return res;\n }\n\n const { value } = res;\n const { errors } = value;\n if (errors != null && errors.length > 0) {\n return {\n success: false,\n err: new Error(errors[0].message),\n };\n }\n\n if (!value.data || !value.data.user || !value.data.user.result) {\n return {\n success: false,\n err: new Error('User not found.'),\n };\n }\n\n const { result: user } = value.data.user;\n const { legacy } = user;\n\n if (legacy.screen_name == null || legacy.screen_name.length === 0) {\n return {\n success: false,\n err: new Error(\n `Either user with ID ${userId} does not exist or is private.`,\n ),\n };\n }\n\n return {\n success: true,\n value: legacy.screen_name,\n };\n}\n\nexport async function getUserIdByScreenName(\n screenName: string,\n auth: TwitterAuth,\n): Promise<RequestApiResult<string>> {\n const cached = idCache.get(screenName);\n if (cached != null) {\n return { success: true, value: cached };\n }\n\n const profileRes = await getProfile(screenName, auth);\n if (!profileRes.success) {\n return profileRes;\n }\n\n const profile = profileRes.value;\n if (profile.userId != null) {\n idCache.set(screenName, profile.userId);\n\n return {\n success: true,\n value: profile.userId,\n };\n }\n\n return {\n success: false,\n err: new Error('User ID is undefined.'),\n };\n}\n","import { TwitterAuthOptions, TwitterGuestAuth } from './auth';\nimport { requestApi } from './api';\nimport { CookieJar } from 'tough-cookie';\nimport { updateCookieJar } from './requests';\nimport { Headers } from 'headers-polyfill';\nimport { TwitterApiErrorRaw } from './errors';\nimport { Type, type Static } from '@sinclair/typebox';\nimport { Check } from '@sinclair/typebox/value';\nimport * as OTPAuth from 'otpauth';\nimport { LegacyUserRaw, parseProfile, type Profile } from './profile';\n\ninterface TwitterUserAuthFlowInitRequest {\n flow_name: string;\n input_flow_data: Record<string, unknown>;\n}\n\ninterface TwitterUserAuthFlowSubtaskRequest {\n flow_token: string;\n subtask_inputs: ({\n subtask_id: string;\n } & Record<string, unknown>)[];\n}\n\ntype TwitterUserAuthFlowRequest =\n | TwitterUserAuthFlowInitRequest\n | TwitterUserAuthFlowSubtaskRequest;\n\ninterface TwitterUserAuthFlowResponse {\n errors?: TwitterApiErrorRaw[];\n flow_token?: string;\n status?: string;\n subtasks?: TwitterUserAuthSubtask[];\n}\n\ninterface TwitterUserAuthVerifyCredentials {\n errors?: TwitterApiErrorRaw[];\n}\n\nconst TwitterUserAuthSubtask = Type.Object({\n subtask_id: Type.String(),\n enter_text: Type.Optional(Type.Object({})),\n});\ntype TwitterUserAuthSubtask = Static<typeof TwitterUserAuthSubtask>;\n\ntype FlowTokenResultSuccess = {\n status: 'success';\n flowToken: string;\n subtask?: TwitterUserAuthSubtask;\n};\n\ntype FlowTokenResult = FlowTokenResultSuccess | { status: 'error'; err: Error };\n\n/**\n * A user authentication token manager.\n */\nexport class TwitterUserAuth extends TwitterGuestAuth {\n private userProfile: Profile | undefined;\n\n constructor(bearerToken: string, options?: Partial<TwitterAuthOptions>) {\n super(bearerToken, options);\n }\n\n async isLoggedIn(): Promise<boolean> {\n const res = await requestApi<TwitterUserAuthVerifyCredentials>(\n 'https://api.twitter.com/1.1/account/verify_credentials.json',\n this,\n );\n if (!res.success) {\n return false;\n }\n\n const { value: verify } = res;\n this.userProfile = parseProfile(\n verify as LegacyUserRaw,\n (verify as unknown as { verified: boolean }).verified,\n );\n return verify && !verify.errors?.length;\n }\n\n async me(): Promise<Profile | undefined> {\n if (this.userProfile) {\n return this.userProfile;\n }\n await this.isLoggedIn();\n return this.userProfile;\n }\n\n async login(\n username: string,\n password: string,\n email?: string,\n twoFactorSecret?: string,\n appKey?: string,\n appSecret?: string,\n accessToken?: string,\n accessSecret?: string,\n ): Promise<void> {\n await this.updateGuestToken();\n\n let next = await this.initLogin();\n while ('subtask' in next && next.subtask) {\n if (next.subtask.subtask_id === 'LoginJsInstrumentationSubtask') {\n next = await this.handleJsInstrumentationSubtask(next);\n } else if (next.subtask.subtask_id === 'LoginEnterUserIdentifierSSO') {\n next = await this.handleEnterUserIdentifierSSO(next, username);\n } else if (\n next.subtask.subtask_id === 'LoginEnterAlternateIdentifierSubtask'\n ) {\n next = await this.handleEnterAlternateIdentifierSubtask(\n next,\n email as string,\n );\n } else if (next.subtask.subtask_id === 'LoginEnterPassword') {\n next = await this.handleEnterPassword(next, password);\n } else if (next.subtask.subtask_id === 'AccountDuplicationCheck') {\n next = await this.handleAccountDuplicationCheck(next);\n } else if (next.subtask.subtask_id === 'LoginTwoFactorAuthChallenge') {\n if (twoFactorSecret) {\n next = await this.handleTwoFactorAuthChallenge(next, twoFactorSecret);\n } else {\n throw new Error(\n 'Requested two factor authentication code but no secret provided',\n );\n }\n } else if (next.subtask.subtask_id === 'LoginAcid') {\n next = await this.handleAcid(next, email);\n } else if (next.subtask.subtask_id === 'LoginSuccessSubtask') {\n next = await this.handleSuccessSubtask(next);\n } else {\n throw new Error(`Unknown subtask ${next.subtask.subtask_id}`);\n }\n }\n if (appKey && appSecret && accessToken && accessSecret) {\n this.loginWithV2(appKey, appSecret, accessToken, accessSecret);\n }\n if ('err' in next) {\n throw next.err;\n }\n }\n\n async logout(): Promise<void> {\n if (!this.isLoggedIn()) {\n return;\n }\n\n await requestApi<void>(\n 'https://api.twitter.com/1.1/account/logout.json',\n this,\n 'POST',\n );\n this.deleteToken();\n this.jar = new CookieJar();\n }\n\n async installCsrfToken(headers: Headers): Promise<void> {\n const cookies = await this.getCookies();\n const xCsrfToken = cookies.find((cookie) => cookie.key === 'ct0');\n if (xCsrfToken) {\n headers.set('x-csrf-token', xCsrfToken.value);\n }\n }\n\n async installTo(headers: Headers): Promise<void> {\n headers.set('authorization', `Bearer ${this.bearerToken}`);\n headers.set('cookie', await this.getCookieString());\n await this.installCsrfToken(headers);\n }\n\n private async initLogin() {\n // Reset certain session-related cookies because Twitter complains sometimes if we don't\n this.removeCookie('twitter_ads_id=');\n this.removeCookie('ads_prefs=');\n this.removeCookie('_twitter_sess=');\n this.removeCookie('zipbox_forms_auth_token=');\n this.removeCookie('lang=');\n this.removeCookie('bouncer_reset_cookie=');\n this.removeCookie('twid=');\n this.removeCookie('twitter_ads_idb=');\n this.removeCookie('email_uid=');\n this.removeCookie('external_referer=');\n this.removeCookie('ct0=');\n this.removeCookie('aa_u=');\n\n return await this.executeFlowTask({\n flow_name: 'login',\n input_flow_data: {\n flow_context: {\n debug_overrides: {},\n start_location: {\n location: 'splash_screen',\n },\n },\n },\n });\n }\n\n private async handleJsInstrumentationSubtask(prev: FlowTokenResultSuccess) {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [\n {\n subtask_id: 'LoginJsInstrumentationSubtask',\n js_instrumentation: {\n response: '{}',\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleEnterAlternateIdentifierSubtask(\n prev: FlowTokenResultSuccess,\n email: string,\n ) {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [\n {\n subtask_id: 'LoginEnterAlternateIdentifierSubtask',\n enter_text: {\n text: email,\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleEnterUserIdentifierSSO(\n prev: FlowTokenResultSuccess,\n username: string,\n ) {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [\n {\n subtask_id: 'LoginEnterUserIdentifierSSO',\n settings_list: {\n setting_responses: [\n {\n key: 'user_identifier',\n response_data: {\n text_data: { result: username },\n },\n },\n ],\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleEnterPassword(\n prev: FlowTokenResultSuccess,\n password: string,\n ) {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [\n {\n subtask_id: 'LoginEnterPassword',\n enter_password: {\n password,\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleAccountDuplicationCheck(prev: FlowTokenResultSuccess) {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [\n {\n subtask_id: 'AccountDuplicationCheck',\n check_logged_in_account: {\n link: 'AccountDuplicationCheck_false',\n },\n },\n ],\n });\n }\n\n private async handleTwoFactorAuthChallenge(\n prev: FlowTokenResultSuccess,\n secret: string,\n ) {\n const totp = new OTPAuth.TOTP({ secret });\n let error;\n for (let attempts = 1; attempts < 4; attempts += 1) {\n try {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [\n {\n subtask_id: 'LoginTwoFactorAuthChallenge',\n enter_text: {\n link: 'next_link',\n text: totp.generate(),\n },\n },\n ],\n });\n } catch (err) {\n error = err;\n await new Promise((resolve) => setTimeout(resolve, 2000 * attempts));\n }\n }\n throw error;\n }\n\n private async handleAcid(\n prev: FlowTokenResultSuccess,\n email: string | undefined,\n ) {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [\n {\n subtask_id: 'LoginAcid',\n enter_text: {\n text: email,\n link: 'next_link',\n },\n },\n ],\n });\n }\n\n private async handleSuccessSubtask(prev: FlowTokenResultSuccess) {\n return await this.executeFlowTask({\n flow_token: prev.flowToken,\n subtask_inputs: [],\n });\n }\n\n private async executeFlowTask(\n data: TwitterUserAuthFlowRequest,\n ): Promise<FlowTokenResult> {\n const onboardingTaskUrl =\n 'https://api.twitter.com/1.1/onboarding/task.json';\n\n const token = this.guestToken;\n if (token == null) {\n throw new Error('Authentication token is null or undefined.');\n }\n\n const headers = new Headers({\n authorization: `Bearer ${this.bearerToken}`,\n cookie: await this.getCookieString(),\n 'content-type': 'application/json',\n 'User-Agent':\n 'Mozilla/5.0 (Linux; Android 11; Nokia G20) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.88 Mobile Safari/537.36',\n 'x-guest-token': token,\n 'x-twitter-auth-type': 'OAuth2Client',\n 'x-twitter-active-user': 'yes',\n 'x-twitter-client-language': 'en',\n });\n await this.installCsrfToken(headers);\n\n const res = await this.fetch(onboardingTaskUrl, {\n credentials: 'include',\n method: 'POST',\n headers: headers,\n body: JSON.stringify(data),\n });\n\n await updateCookieJar(this.jar, res.headers);\n\n if (!res.ok) {\n return { status: 'error', err: new Error(await res.text()) };\n }\n\n const flow: TwitterUserAuthFlowResponse = await res.json();\n if (flow?.flow_token == null) {\n return { status: 'error', err: new Error('flow_token not found.') };\n }\n\n if (flow.errors?.length) {\n return {\n status: 'error',\n err: new Error(\n `Authentication error (${flow.errors[0].code}): ${flow.errors[0].message}`,\n ),\n };\n }\n\n if (typeof flow.flow_token !== 'string') {\n return {\n status: 'error',\n err: new Error('flow_token was not a string.'),\n };\n }\n\n const subtask = flow.subtasks?.length ? flow.subtasks[0] : undefined;\n Check(TwitterUserAuthSubtask, subtask);\n\n if (subtask && subtask.subtask_id === 'DenyLoginSubtask') {\n return {\n status: 'error',\n err: new Error('Authentication error: DenyLoginSubtask'),\n };\n }\n\n return {\n status: 'success',\n subtask,\n flowToken: flow.flow_token,\n };\n }\n}\n","import { Profile } from './profile';\nimport { Tweet } from './tweets';\n\nexport interface FetchProfilesResponse {\n profiles: Profile[];\n next?: string;\n}\n\nexport type FetchProfiles = (\n query: string,\n maxProfiles: number,\n cursor: string | undefined,\n) => Promise<FetchProfilesResponse>;\n\nexport interface FetchTweetsResponse {\n tweets: Tweet[];\n next?: string;\n}\n\nexport type FetchTweets = (\n query: string,\n maxTweets: number,\n cursor: string | undefined,\n) => Promise<FetchTweetsResponse>;\n\nexport async function* getUserTimeline(\n query: string,\n maxProfiles: number,\n fetchFunc: FetchProfiles,\n): AsyncGenerator<Profile, void> {\n let nProfiles = 0;\n let cursor: string | undefined = undefined;\n let consecutiveEmptyBatches = 0;\n while (nProfiles < maxProfiles) {\n const batch: FetchProfilesResponse = await fetchFunc(\n query,\n maxProfiles,\n cursor,\n );\n\n const { profiles, next } = batch;\n cursor = next;\n\n if (profiles.length === 0) {\n consecutiveEmptyBatches++;\n if (consecutiveEmptyBatches > 5) break;\n } else consecutiveEmptyBatches = 0;\n\n for (const profile of profiles) {\n if (nProfiles < maxProfiles) yield profile;\n else break;\n nProfiles++;\n }\n\n if (!next) break;\n }\n}\n\nexport async function* getTweetTimeline(\n query: string,\n maxTweets: number,\n fetchFunc: FetchTweets,\n): AsyncGenerator<Tweet, void> {\n let nTweets = 0;\n let cursor: string | undefined = undefined;\n while (nTweets < maxTweets) {\n const batch: FetchTweetsResponse = await fetchFunc(\n query,\n maxTweets,\n cursor,\n );\n\n const { tweets, next } = batch;\n\n if (tweets.length === 0) {\n break;\n }\n\n for (const tweet of tweets) {\n if (nTweets < maxTweets) {\n cursor = next;\n yield tweet;\n } else {\n break;\n }\n\n nTweets++;\n }\n }\n}\n","export type NonNullableField<T, K extends keyof T> = {\n [P in K]-?: T[P];\n} & T;\n\nexport function isFieldDefined<T, K extends keyof T>(key: K) {\n return function (value: T): value is NonNullableField<T, K> {\n return isDefined(value[key]);\n };\n}\n\nexport function isDefined<T>(value: T | null | undefined): value is T {\n return value != null;\n}\n","import { LegacyTweetRaw, TimelineMediaExtendedRaw } from './timeline-v1';\nimport { Photo, Video } from './tweets';\nimport { isFieldDefined, NonNullableField } from './type-util';\n\nconst reHashtag = /\\B(\\#\\S+\\b)/g;\nconst reCashtag = /\\B(\\$\\S+\\b)/g;\nconst reTwitterUrl = /https:(\\/\\/t\\.co\\/([A-Za-z0-9]|[A-Za-z]){10})/g;\nconst reUsername = /\\B(\\@\\S{1,15}\\b)/g;\n\nexport function parseMediaGroups(media: TimelineMediaExtendedRaw[]): {\n sensitiveContent?: boolean;\n photos: Photo[];\n videos: Video[];\n} {\n const photos: Photo[] = [];\n const videos: Video[] = [];\n let sensitiveContent: boolean | undefined = undefined;\n\n for (const m of media\n .filter(isFieldDefined('id_str'))\n .filter(isFieldDefined('media_url_https'))) {\n if (m.type === 'photo') {\n photos.push({\n id: m.id_str,\n url: m.media_url_https,\n alt_text: m.ext_alt_text,\n });\n } else if (m.type === 'video') {\n videos.push(parseVideo(m));\n }\n\n const sensitive = m.ext_sensitive_media_warning;\n if (sensitive != null) {\n sensitiveContent =\n sensitive.adult_content ||\n sensitive.graphic_violence ||\n sensitive.other;\n }\n }\n\n return { sensitiveContent, photos, videos };\n}\n\nfunction parseVideo(\n m: NonNullableField<TimelineMediaExtendedRaw, 'id_str' | 'media_url_https'>,\n): Video {\n const video: Video = {\n id: m.id_str,\n preview: m.media_url_https,\n };\n\n let maxBitrate = 0;\n const variants = m.video_info?.variants ?? [];\n for (const variant of variants) {\n const bitrate = variant.bitrate;\n if (bitrate != null && bitrate > maxBitrate && variant.url != null) {\n let variantUrl = variant.url;\n const stringStart = 0;\n const tagSuffixIdx = variantUrl.indexOf('?tag=10');\n if (tagSuffixIdx !== -1) {\n variantUrl = variantUrl.substring(stringStart, tagSuffixIdx + 1);\n }\n\n video.url = variantUrl;\n maxBitrate = bitrate;\n }\n }\n\n return video;\n}\n\nexport function reconstructTweetHtml(\n tweet: LegacyTweetRaw,\n photos: Photo[],\n videos: Video[],\n): string {\n const media: string[] = [];\n\n // HTML parsing with regex :)\n let html = tweet.full_text ?? '';\n\n html = html.replace(reHashtag, linkHashtagHtml);\n html = html.replace(reCashtag, linkCashtagHtml);\n html = html.replace(reUsername, linkUsernameHtml);\n html = html.replace(reTwitterUrl, unwrapTcoUrlHtml(tweet, media));\n\n for (const { url } of photos) {\n if (media.indexOf(url) !== -1) {\n continue;\n }\n\n html += `<br><img src=\"${url}\"/>`;\n }\n\n for (const { preview: url } of videos) {\n if (media.indexOf(url) !== -1) {\n continue;\n }\n\n html += `<br><img src=\"${url}\"/>`;\n }\n\n html = html.replace(/\\n/g, '<br>');\n\n return html;\n}\n\nfunction linkHashtagHtml(hashtag: string) {\n return `<a href=\"https://twitter.com/hashtag/${hashtag.replace(\n '#',\n '',\n )}\">${hashtag}</a>`;\n}\n\nfunction linkCashtagHtml(cashtag: string) {\n return `<a href=\"https://twitter.com/search?q=%24${cashtag.replace(\n '$',\n '',\n )}\">${cashtag}</a>`;\n}\n\nfunction linkUsernameHtml(username: string) {\n return `<a href=\"https://twitter.com/${username.replace(\n '@',\n '',\n )}\">${username}</a>`;\n}\n\nfunction unwrapTcoUrlHtml(tweet: LegacyTweetRaw, foundedMedia: string[]) {\n return function (tco: string) {\n for (const entity of tweet.entities?.urls ?? []) {\n if (tco === entity.url && entity.expanded_url != null) {\n return `<a href=\"${entity.expanded_url}\">${tco}</a>`;\n }\n }\n\n for (const entity of tweet.extended_entities?.media ?? []) {\n if (tco === entity.url && entity.media_url_https != null) {\n foundedMedia.push(entity.media_url_https);\n return `<br><a href=\"${tco}\"><img src=\"${entity.media_url_https}\"/></a>`;\n }\n }\n\n return tco;\n };\n}\n","import { LegacyUserRaw } from './profile';\nimport { parseMediaGroups, reconstructTweetHtml } from './timeline-tweet-util';\nimport {\n LegacyTweetRaw,\n ParseTweetResult,\n QueryTweetsResponse,\n SearchResultRaw,\n TimelineResultRaw,\n} from './timeline-v1';\nimport { Tweet } from './tweets';\nimport { isFieldDefined } from './type-util';\n\nexport interface TimelineUserResultRaw {\n rest_id?: string;\n legacy?: LegacyUserRaw;\n is_blue_verified?: boolean;\n}\n\nexport interface TimelineEntryItemContentRaw {\n itemType?: string;\n tweetDisplayType?: string;\n tweetResult?: {\n result?: TimelineResultRaw;\n };\n tweet_results?: {\n result?: TimelineResultRaw;\n };\n userDisplayType?: string;\n user_results?: {\n result?: TimelineUserResultRaw;\n };\n}\n\nexport interface TimelineEntryRaw {\n entryId: string;\n content?: {\n cursorType?: string;\n value?: string;\n items?: {\n entryId?: string;\n item?: {\n content?: TimelineEntryItemContentRaw;\n itemContent?: SearchEntryItemContentRaw;\n };\n }[];\n itemContent?: TimelineEntryItemContentRaw;\n };\n}\n\nexport interface SearchEntryItemContentRaw {\n tweetDisplayType?: string;\n tweet_results?: {\n result?: SearchResultRaw;\n };\n userDisplayType?: string;\n user_results?: {\n result?: TimelineUserResultRaw;\n };\n}\n\nexport interface SearchEntryRaw {\n entryId: string;\n sortIndex: string;\n content?: {\n cursorType?: string;\n entryType?: string;\n __typename?: string;\n value?: string;\n items?: {\n item?: {\n content?: SearchEntryItemContentRaw;\n };\n }[];\n itemContent?: SearchEntryItemContentRaw;\n };\n}\n\nexport interface TimelineInstruction {\n entries?: TimelineEntryRaw[];\n entry?: TimelineEntryRaw;\n type?: string;\n}\n\nexport interface TimelineV2 {\n data?: {\n user?: {\n result?: {\n timeline_v2?: {\n timeline?: {\n instructions?: TimelineInstruction[];\n };\n };\n };\n };\n };\n}\n\nexport interface ThreadedConversation {\n data?: {\n threaded_conversation_with_injections_v2?: {\n instructions?: TimelineInstruction[];\n };\n };\n}\n\nexport function parseLegacyTweet(\n user?: LegacyUserRaw,\n tweet?: LegacyTweetRaw,\n): ParseTweetResult {\n if (tweet == null) {\n return {\n success: false,\n err: new Error('Tweet was not found in the timeline object.'),\n };\n }\n\n if (user == null) {\n return {\n success: false,\n err: new Error('User was not found in the timeline object.'),\n };\n }\n\n if (!tweet.id_str) {\n if (!tweet.conversation_id_str) {\n return {\n success: false,\n err: new Error('Tweet ID was not found in object.'),\n };\n }\n\n tweet.id_str = tweet.conversation_id_str;\n }\n\n const hashtags = tweet.entities?.hashtags ?? [];\n const mentions = tweet.entities?.user_mentions ?? [];\n const media = tweet.extended_entities?.media ?? [];\n const pinnedTweets = new Set<string | undefined>(\n user.pinned_tweet_ids_str ?? [],\n );\n const urls = tweet.entities?.urls ?? [];\n const { photos, videos, sensitiveContent } = parseMediaGroups(media);\n\n const tw: Tweet = {\n bookmarkCount: tweet.bookmark_count,\n conversationId: tweet.conversation_id_str,\n id: tweet.id_str,\n hashtags: hashtags\n .filter(isFieldDefined('text'))\n .map((hashtag) => hashtag.text),\n likes: tweet.favorite_count,\n mentions: mentions.filter(isFieldDefined('id_str')).map((mention) => ({\n id: mention.id_str,\n username: mention.screen_name,\n name: mention.name,\n })),\n name: user.name,\n permanentUrl: `https://twitter.com/${user.screen_name}/status/${tweet.id_str}`,\n photos,\n replies: tweet.reply_count,\n retweets: tweet.retweet_count,\n text: tweet.full_text,\n thread: [],\n urls: urls\n .filter(isFieldDefined('expanded_url'))\n .map((url) => url.expanded_url),\n userId: tweet.user_id_str,\n username: user.screen_name,\n videos,\n isQuoted: false,\n isReply: false,\n is