From c3dfdf4f4b2b2cf3e50fde3a03ec470745e7b31d Mon Sep 17 00:00:00 2001 From: Tony Date: Sat, 10 Oct 2026 23:49:40 +0800 Subject: [PATCH] refactor(twitter): simplify spaces --- .../twitter/api/web-api/gql-id-resolver.ts | 9 +- lib/routes/twitter/spaces.ts | 94 ++++--------------- 2 files changed, 23 insertions(+), 80 deletions(-) diff --git a/lib/routes/twitter/api/web-api/gql-id-resolver.ts b/lib/routes/twitter/api/web-api/gql-id-resolver.ts index aaed8738be..99437fc384 100644 --- a/lib/routes/twitter/api/web-api/gql-id-resolver.ts +++ b/lib/routes/twitter/api/web-api/gql-id-resolver.ts @@ -17,7 +17,7 @@ export const fallbackIds = { SearchTimeline: 'BGd0T_j7oVwlW5U79tO_0A', ListLatestTweetsTimeline: 'jW040BLUjh8X6Tw2ODQufA', TweetDetail: '559hs_YZNV4IgA3Z6zIIuw', - AudioSpaceById: '', + AudioSpaceById: 'ipEqgz4P_bZCGY3fBnfsxQ', }; const operationNames = Object.keys(fallbackIds); @@ -41,13 +41,13 @@ async function fetchAndExtractIds(): Promise> { let resolvePromise: Promise> | null = null; -export async function resolveQueryIds(requiredOperations: string[] = []): Promise> { +export async function resolveQueryIds(): Promise> { // Check cache first const cached = await cache.get(CACHE_KEY); if (cached) { try { const parsed: Record = JSON.parse(cached); - if (Object.keys(parsed).length > 0 && requiredOperations.every((operation) => parsed[operation])) { + if (Object.keys(parsed).length > 0) { logger.debug('twitter gql-id-resolver: using cached query IDs'); return { ...fallbackIds, ...parsed }; } @@ -90,9 +90,6 @@ export function buildGqlMap(queryIds: Record) { const map: Record = {}; for (const name of operationNames) { const id = queryIds[name] || fallbackIds[name]; - if (!id) { - continue; - } map[name] = `/graphql/${id}/${name}`; } return map; diff --git a/lib/routes/twitter/spaces.ts b/lib/routes/twitter/spaces.ts index 6dcfee1426..0c3ca35e48 100644 --- a/lib/routes/twitter/spaces.ts +++ b/lib/routes/twitter/spaces.ts @@ -1,7 +1,5 @@ import type { Context } from 'hono'; -import { config } from '@/config'; -import ConfigNotFoundError from '@/errors/types/config-not-found'; import InvalidParameterError from '@/errors/types/invalid-parameter'; import type { Data, Route } from '@/types'; import cache from '@/utils/cache'; @@ -11,28 +9,22 @@ import { baseUrl, gqlFeatures } from './api/web-api/constants'; import { buildGqlMap, resolveQueryIds } from './api/web-api/gql-id-resolver'; import { twitterGot } from './api/web-api/utils'; -interface ApiErrors { - errors?: Array<{ code?: number }>; -} - -export interface SpaceProfile { +interface SpaceProfile { rest_id: string; - core?: { name?: string; screen_name?: string }; - legacy?: { name?: string; screen_name?: string }; + core: { name: string; screen_name: string }; } interface SpaceParticipant { - twitter_screen_name?: string; - user_results?: { rest_id?: string; result?: { rest_id?: string } }; + user_results: { rest_id: string }; } -export interface AudioSpace { - metadata: { rest_id: string; state: string; title?: string; started_at?: number }; +interface AudioSpace { + metadata: { rest_id: string; state: string; title?: string; started_at: number }; participants: { admins: SpaceParticipant[]; speakers: SpaceParticipant[] }; } -interface PresenceResponse extends ApiErrors { - users: Record; +interface PresenceResponse { + users: Record; } const audioSpaceFeatures = { @@ -71,63 +63,36 @@ export const route: Route = { example: '/twitter/spaces/_RSSHub', parameters: { username: 'The X username, without @.' }, features: { - requireConfig: [{ name: 'TWITTER_AUTH_TOKEN', description: 'An authorized login session for the X web API. Developer API keys and third-party timeline providers are not used by this route.' }], + requireConfig: [{ name: 'TWITTER_AUTH_TOKEN', description: 'Please see above for details.' }], }, maintainers: ['DIYgod'], description: - 'Reports a user speaking in a live Space, including Spaces hosted by other users. Hosts and co-hosts are also included; listeners are excluded. Each user/Space pair has a stable entry ID and uses the actual Space start time. When the user is not speaking in a live Space, a status entry has a fixed ID and no publication date. This does not join or listen to a Space. Requires your own authorized TWITTER\\_AUTH\\_TOKEN on a self-hosted instance.', + 'Reports a user speaking in a live Space, including Spaces hosted by other users. Hosts and co-hosts are also included; listeners are excluded. Each user/Space pair has a stable entry ID and uses the actual Space start time. When the user is not in a live Space, or is in one without speaking, a status entry says so with a fixed ID and no publication date. This does not join or listen to a Space.', radar: [{ source: ['x.com/:username'], target: '/spaces/:username' }], handler, }; -function checkErrors(response: ApiErrors, operation: string) { - if (response.errors !== undefined && (!Array.isArray(response.errors) || response.errors.length)) { - throw new Error(`X ${operation} failed. Check the TWITTER_AUTH_TOKEN authorization and X API access.`); - } -} - function participantMatches(participant: SpaceParticipant, user: SpaceProfile) { - const restId = participant.user_results?.rest_id ?? participant.user_results?.result?.rest_id; - if (restId !== undefined) { - return restId === user.rest_id; - } - const username = user.core?.screen_name ?? user.legacy?.screen_name; - return !!(username && participant.twitter_screen_name?.toLowerCase() === username.toLowerCase()); + return participant.user_results.rest_id === user.rest_id; } -export function buildSpaceFeed(user: SpaceProfile, space?: AudioSpace): Data { - const username = user.core?.screen_name ?? user.legacy?.screen_name; - if (!username || typeof user.rest_id !== 'string' || !/^\d+$/.test(user.rest_id)) { - throw new Error('X returned incomplete user metadata.'); - } - const name = user.core?.name ?? user.legacy?.name ?? username; +function buildSpaceFeed(user: SpaceProfile, space?: AudioSpace): Data { + const { name, screen_name: username } = user.core; const link = `https://x.com/${username}`; const data: Data = { title: `${username} - Space speaking status`, link, - item: [{ title: `${name} is not speaking in a live Space`, link, author: name, guid: `${user.rest_id}:spaces:inactive` }], + item: [{ title: `${name} is not ${space ? 'speaking ' : ''}in a live Space`, link, author: name, guid: `${user.rest_id}:spaces:inactive` }], }; - if (!space) { + if (space?.metadata.state !== 'Running') { return data; } - if (typeof space.metadata?.state !== 'string') { - throw new TypeError('X returned incomplete Space status metadata.'); - } - if (space.metadata.state !== 'Running') { - return data; - } - if (!Array.isArray(space.participants?.admins) || !Array.isArray(space.participants?.speakers)) { - throw new TypeError('X did not provide the Space speaker list.'); - } const host = space.participants.admins.some((participant) => participantMatches(participant, user)); const speaker = space.participants.speakers.some((participant) => participantMatches(participant, user)); if (!host && !speaker) { return data; } const metadata = space.metadata; - if (!/^[a-z0-9]+$/i.test(metadata.rest_id) || typeof metadata.started_at !== 'number' || !Number.isSafeInteger(metadata.started_at) || metadata.started_at <= 0) { - throw new Error('X did not provide a valid Space ID and actual start time.'); - } data.item = [ { title: `${name} is ${host ? 'hosting' : 'speaking in'} ${metadata.title || 'a live Space'}`, @@ -146,49 +111,30 @@ async function handler(ctx: Context): Promise { if (!/^\w{1,15}$/.test(username)) { throw new InvalidParameterError('Use a valid X username without @.'); } - if (!config.twitter.authToken?.length) { - throw new ConfigNotFoundError('Configure your own authorized TWITTER_AUTH_TOKEN to monitor Space speakers.'); - } const operations = buildGqlMap(await resolveQueryIds()); const user = await cache.tryGet(`twitter:spaces:profile:${username}`, async () => { const response = (await twitterGot(`${baseUrl}${operations.UserByScreenName}`, { variables: JSON.stringify({ screen_name: username, withSafetyModeUserFields: true }), features: JSON.stringify(gqlFeatures.UserByScreenName), fieldToggles: JSON.stringify({ withAuxiliaryUserLabels: false }), - })) as ApiErrors & { data?: { user?: { result?: SpaceProfile } } }; - checkErrors(response, 'user lookup'); + })) as { data?: { user?: { result?: SpaceProfile } } }; const result = response.data?.user?.result; - if (typeof result?.rest_id !== 'string' || !/^\d+$/.test(result.rest_id)) { + if (!result) { throw new Error('X did not return this user. Check the username and authorized session.'); } return result; }); const presence = (await twitterGot(`${baseUrl}/fleets/v1/avatar_content`, { user_ids: user.rest_id })) as PresenceResponse; - checkErrors(presence, 'Space presence lookup'); - if (!presence.users || typeof presence.users !== 'object' || Array.isArray(presence.users)) { - throw new Error('X returned an unexpected Space presence response.'); - } const currentSpace = presence.users[user.rest_id]?.spaces?.live_content?.audiospace; - if (currentSpace && typeof currentSpace.state !== 'string') { - throw new TypeError('X returned incomplete Space presence metadata.'); - } - if (!currentSpace || currentSpace.state !== 'RUNNING') { + if (!currentSpace) { return buildSpaceFeed(user); } - if (!currentSpace.id || !/^[a-z0-9]+$/i.test(currentSpace.id)) { - throw new Error('X returned an invalid current Space ID.'); - } - const spaceOperation = buildGqlMap(await resolveQueryIds(['AudioSpaceById'])).AudioSpaceById; - if (!spaceOperation) { - throw new Error('The current X AudioSpaceById query ID could not be resolved. Try again after the source API metadata is updated.'); - } - const response = (await twitterGot(`${baseUrl}${spaceOperation}`, { + const response = (await twitterGot(`${baseUrl}${operations.AudioSpaceById}`, { variables: JSON.stringify({ id: currentSpace.id, isMetatagsQuery: false, withReplays: true, withListeners: false }), features: JSON.stringify(audioSpaceFeatures), - })) as ApiErrors & { data?: { audioSpace?: AudioSpace } }; - checkErrors(response, 'Space speaker lookup'); + })) as { data?: { audioSpace?: AudioSpace } }; const space = response.data?.audioSpace; - if (!space || space.metadata?.rest_id !== currentSpace.id) { + if (!space) { throw new Error('X did not return metadata for the current Space.'); } return buildSpaceFeed(user, space);